From f99277e0bcd6dff79f1f67e0f933b8d2dfbd8597 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 16 Jun 2026 11:40:47 -0500 Subject: [PATCH 01/27] feat(measurement-jobs): free-tier card gate (setup mode) + instant backfill drain (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two chat#1796 refinements on the historical (Songstats) path: 1. Free-tier card-on-file link. The gate was issuing the paid subscription checkout ($99/mo after a 30-day trial). New createCardOnFileSession uses Stripe Checkout `mode: "setup"` — collects a card for $0, no subscription, no Stripe product. The account then pays only for metered usage via credits. 2. Instant drain. After enqueuing a historical job, fire-and-forget start(songstatsBackfillWorkflow) so the backfill begins immediately instead of waiting up to 24h for the cron. Safe by reuse: the workflow's budget gate (limit − reserve − rolling-30d ledger) caps it to the Songstats quota and SKIP LOCKED prevents double-claiming with the daily cron, which stays as the backstop. Only kicks when something was actually enqueued. 26 new/updated unit tests; research+stripe+workflows suite 453 green; tsc/lint/format clean. --- .../enqueueHistoricalBackfill.test.ts | 19 +++++++++ .../ensureSongstatsPaymentMethod.test.ts | 12 +++--- .../enqueueHistoricalBackfill.ts | 15 +++++++ .../ensureSongstatsPaymentMethod.ts | 7 +++- .../__tests__/createCardOnFileSession.test.ts | 39 +++++++++++++++++++ lib/stripe/createCardOnFileSession.ts | 31 +++++++++++++++ 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 lib/stripe/__tests__/createCardOnFileSession.test.ts create mode 100644 lib/stripe/createCardOnFileSession.ts diff --git a/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts b/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts index 20cde74a7..03010fa41 100644 --- a/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts +++ b/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts @@ -3,6 +3,7 @@ import { enqueueHistoricalBackfill } from "../enqueueHistoricalBackfill"; import { resolveScopeSongs } from "../resolveScopeSongs"; import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; import { upsertSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/upsertSongstatsBackfillQueue"; +import { start } from "workflow/api"; vi.mock("../resolveScopeSongs", () => ({ resolveScopeSongs: vi.fn() })); vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ @@ -11,6 +12,10 @@ vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ vi.mock("@/lib/supabase/songstats_backfill_queue/upsertSongstatsBackfillQueue", () => ({ upsertSongstatsBackfillQueue: vi.fn(), })); +vi.mock("workflow/api", () => ({ start: vi.fn() })); +vi.mock("@/app/workflows/songstatsBackfillWorkflow", () => ({ + songstatsBackfillWorkflow: vi.fn(), +})); describe("enqueueHistoricalBackfill", () => { beforeEach(() => { @@ -47,6 +52,8 @@ describe("enqueueHistoricalBackfill", () => { expect(r).toEqual({ data: { status: "success", source: "historical", id: null, enqueued: 2, skipped: 1 }, }); + // kick the drain immediately so the user doesn't wait for the daily cron + expect(start).toHaveBeenCalledTimes(1); }); it("ranks a song with no prior measurement at 0", async () => { @@ -57,4 +64,16 @@ describe("enqueueHistoricalBackfill", () => { expect(upsertSongstatsBackfillQueue).toHaveBeenCalledWith({ song: "I9", rank_score: 0 }); }); + + it("does NOT kick the drain when everything was already backfilled (nothing enqueued)", async () => { + vi.mocked(resolveScopeSongs).mockResolvedValue(["I1"]); + vi.mocked(selectSongMeasurements).mockResolvedValue([ + { song: "I1", value: 500, data_source: "songstats" }, + ] as never); + + const r = await enqueueHistoricalBackfill({ isrcs: ["I1"] }); + + expect((r as { data: { enqueued: number } }).data.enqueued).toBe(0); + expect(start).not.toHaveBeenCalled(); + }); }); diff --git a/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts b/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts index 1e2c1f072..d75629a53 100644 --- a/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts +++ b/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ensureSongstatsPaymentMethod } from "../ensureSongstatsPaymentMethod"; import { findStripeCustomerForAccount } from "@/lib/stripe/findStripeCustomerForAccount"; import { findDefaultPaymentMethodForCustomer } from "@/lib/stripe/findDefaultPaymentMethodForCustomer"; -import { createStripeSession } from "@/lib/stripe/createStripeSession"; +import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({})) })); vi.mock("@/lib/stripe/findStripeCustomerForAccount", () => ({ @@ -11,7 +11,7 @@ vi.mock("@/lib/stripe/findStripeCustomerForAccount", () => ({ vi.mock("@/lib/stripe/findDefaultPaymentMethodForCustomer", () => ({ findDefaultPaymentMethodForCustomer: vi.fn(), })); -vi.mock("@/lib/stripe/createStripeSession", () => ({ createStripeSession: vi.fn() })); +vi.mock("@/lib/stripe/createCardOnFileSession", () => ({ createCardOnFileSession: vi.fn() })); describe("ensureSongstatsPaymentMethod", () => { beforeEach(() => vi.clearAllMocks()); @@ -23,17 +23,17 @@ describe("ensureSongstatsPaymentMethod", () => { const r = await ensureSongstatsPaymentMethod("acc_1"); expect(r).toBeNull(); - expect(createStripeSession).not.toHaveBeenCalled(); + expect(createCardOnFileSession).not.toHaveBeenCalled(); }); it("402s with a free-tier checkout link when there is no Stripe customer", async () => { vi.mocked(findStripeCustomerForAccount).mockResolvedValue(null); - vi.mocked(createStripeSession).mockResolvedValue({ url: "https://checkout/free" } as never); + vi.mocked(createCardOnFileSession).mockResolvedValue({ url: "https://checkout/free" } as never); const r = await ensureSongstatsPaymentMethod("acc_1"); expect(findDefaultPaymentMethodForCustomer).not.toHaveBeenCalled(); - expect(createStripeSession).toHaveBeenCalledWith("acc_1", expect.any(String)); + expect(createCardOnFileSession).toHaveBeenCalledWith("acc_1", expect.any(String)); expect((r as Response).status).toBe(402); expect(await (r as Response).json()).toMatchObject({ status: "error", @@ -44,7 +44,7 @@ describe("ensureSongstatsPaymentMethod", () => { it("402s with a checkout link when the customer exists but has no card", async () => { vi.mocked(findStripeCustomerForAccount).mockResolvedValue("cus_1"); vi.mocked(findDefaultPaymentMethodForCustomer).mockResolvedValue(null); - vi.mocked(createStripeSession).mockResolvedValue({ url: "https://checkout/free" } as never); + vi.mocked(createCardOnFileSession).mockResolvedValue({ url: "https://checkout/free" } as never); const r = await ensureSongstatsPaymentMethod("acc_1"); diff --git a/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts b/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts index 3c0cc7af5..ffd6f3f20 100644 --- a/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts +++ b/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts @@ -1,6 +1,8 @@ +import { start } from "workflow/api"; import { resolveScopeSongs } from "./resolveScopeSongs"; import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; import { upsertSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/upsertSongstatsBackfillQueue"; +import { songstatsBackfillWorkflow } from "@/app/workflows/songstatsBackfillWorkflow"; import type { CreateMeasurementJobBody } from "./validateCreateMeasurementJobRequest"; const METRIC = "platform_displayed_play_count"; @@ -61,5 +63,18 @@ export async function enqueueHistoricalBackfill( enqueued += batch.length; } + // Kick the drain now instead of waiting for the daily cron. The workflow's own + // budget gate (limit − reserve − rolling-30d ledger) means it only drains what + // the Songstats quota allows and then stops; SKIP LOCKED claims keep it from + // double-processing alongside the cron, which stays as the backstop. Fire-and- + // forget — a scheduling hiccup must not fail the (already-enqueued) job. + if (enqueued > 0) { + try { + await start(songstatsBackfillWorkflow); + } catch (error) { + console.error("[measurement-jobs] failed to kick backfill drain:", error); + } + } + return { data: { status: "success", source: "historical", id: null, enqueued, skipped } }; } diff --git a/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts b/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts index 71e3dd37c..8654dd19e 100644 --- a/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts +++ b/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { findStripeCustomerForAccount } from "@/lib/stripe/findStripeCustomerForAccount"; import { findDefaultPaymentMethodForCustomer } from "@/lib/stripe/findDefaultPaymentMethodForCustomer"; -import { createStripeSession } from "@/lib/stripe/createStripeSession"; +import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; /** @@ -22,7 +22,10 @@ export async function ensureSongstatsPaymentMethod( const paymentMethod = customerId ? await findDefaultPaymentMethodForCustomer(customerId) : null; if (paymentMethod) return null; - const session = await createStripeSession(accountId, CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL); + const session = await createCardOnFileSession( + accountId, + CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL, + ); return NextResponse.json( { status: "error", diff --git a/lib/stripe/__tests__/createCardOnFileSession.test.ts b/lib/stripe/__tests__/createCardOnFileSession.test.ts new file mode 100644 index 000000000..14d1e0016 --- /dev/null +++ b/lib/stripe/__tests__/createCardOnFileSession.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { checkoutSessionsCreate, resolveStripeCustomerForAccountMock } = vi.hoisted(() => ({ + checkoutSessionsCreate: vi.fn(), + resolveStripeCustomerForAccountMock: vi.fn(), +})); + +vi.mock("@/lib/stripe/client", () => ({ + default: { checkout: { sessions: { create: checkoutSessionsCreate } } }, +})); +vi.mock("@/lib/stripe/resolveStripeCustomerForAccount", () => ({ + resolveStripeCustomerForAccount: resolveStripeCustomerForAccountMock, +})); + +const { createCardOnFileSession } = await import("@/lib/stripe/createCardOnFileSession"); + +describe("createCardOnFileSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + checkoutSessionsCreate.mockResolvedValue({ id: "cs_x", url: "https://checkout/setup" }); + resolveStripeCustomerForAccountMock.mockResolvedValue("cus_acc1"); + }); + + it("creates a setup-mode session (collect card only, no subscription/price)", async () => { + await createCardOnFileSession("acc-1", "https://example.com/success"); + + expect(resolveStripeCustomerForAccountMock).toHaveBeenCalledWith("acc-1"); + const params = checkoutSessionsCreate.mock.calls[0][0]; + expect(params).toMatchObject({ + customer: "cus_acc1", + mode: "setup", + client_reference_id: "acc-1", + success_url: "https://example.com/success", + }); + // free tier: no subscription, no line_items/price + expect(params.mode).not.toBe("subscription"); + expect(params.line_items).toBeUndefined(); + }); +}); diff --git a/lib/stripe/createCardOnFileSession.ts b/lib/stripe/createCardOnFileSession.ts new file mode 100644 index 000000000..f94959542 --- /dev/null +++ b/lib/stripe/createCardOnFileSession.ts @@ -0,0 +1,31 @@ +import type Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; +import { resolveStripeCustomerForAccount } from "@/lib/stripe/resolveStripeCustomerForAccount"; + +/** + * A Stripe Checkout session that **only collects a card on file** — `mode: + * "setup"`, $0, no subscription or price. This is the "free tier": the account + * saves a payment method (so metered Songstats usage can be charged later via + * the credits system) without committing to any recurring plan. Needs no Stripe + * product. Contrast with {@link createStripeSession}, which is the paid + * subscription flow. + * + * @param accountId - The account to attach the card to. + * @param successUrl - Where Stripe redirects after the card is saved. + */ +export async function createCardOnFileSession( + accountId: string, + successUrl: string, +): Promise { + const metadata = { accountId }; + const customer = await resolveStripeCustomerForAccount(accountId); + + return stripeClient.checkout.sessions.create({ + customer, + mode: "setup", + currency: "usd", + client_reference_id: accountId, + metadata, + success_url: successUrl, + }); +} From 5fa5e3ac7c580ba52c5c53db7a9bdc2f7216035b Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 16 Jun 2026 16:41:22 -0500 Subject: [PATCH 02/27] fix(songstats-backfill): backoff on 429 + defer instead of churn (chat#1797) (#673) Pacing/backoff + per-step logging for the Songstats backfill drain (chat#1797 bullets 1 & 3). Bounded exponential backoff (fetchSongstatsWithBackoff, 502/503/504/408/429), defer-to-pending past the bound with claimed-batch release, per-step + per-batch logging. --- .../__tests__/backfillTrackStep.test.ts | 92 +++++++++---------- .../songstatsBackfillWorkflow.test.ts | 61 ++++++++++++ app/workflows/backfillTrackStep.ts | 59 +++++++----- app/workflows/releaseClaimedRowsStep.ts | 15 +++ app/workflows/songstatsBackfillWorkflow.ts | 38 ++++++-- .../fetchSongstatsWithBackoff.test.ts | 91 ++++++++++++++++++ .../__tests__/isRetryableStatus.test.ts | 26 ++++++ lib/songstats/fetchSongstatsWithBackoff.ts | 61 ++++++++++++ lib/songstats/isRetryableStatus.ts | 14 +++ .../updateSongstatsBackfillQueue.test.ts | 32 +++++-- .../updateSongstatsBackfillQueue.ts | 15 ++- 11 files changed, 406 insertions(+), 98 deletions(-) create mode 100644 app/workflows/__tests__/songstatsBackfillWorkflow.test.ts create mode 100644 app/workflows/releaseClaimedRowsStep.ts create mode 100644 lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts create mode 100644 lib/songstats/__tests__/isRetryableStatus.test.ts create mode 100644 lib/songstats/fetchSongstatsWithBackoff.ts create mode 100644 lib/songstats/isRetryableStatus.ts diff --git a/app/workflows/__tests__/backfillTrackStep.test.ts b/app/workflows/__tests__/backfillTrackStep.test.ts index ebe773704..6c211ed61 100644 --- a/app/workflows/__tests__/backfillTrackStep.test.ts +++ b/app/workflows/__tests__/backfillTrackStep.test.ts @@ -1,12 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { backfillTrackStep } from "../backfillTrackStep"; -import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; +import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; -vi.mock("@/lib/songstats/fetchSongstats", () => ({ fetchSongstats: vi.fn() })); +vi.mock("@/lib/songstats/fetchSongstatsWithBackoff", () => ({ + fetchSongstatsWithBackoff: vi.fn(), +})); vi.mock("@/lib/supabase/song_measurements/upsertSongMeasurements", () => ({ upsertSongMeasurements: vi.fn(), })); @@ -22,22 +24,20 @@ const ROW = { id: "q1", song: "USA2P2015959" } as never; describe("backfillTrackStep", () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); vi.mocked(upsertSongMeasurements).mockResolvedValue([] as never); }); - it("writes the historic series as songstats measurements, records spend, marks done", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ + it("writes the historic series, records the spend, marks done on 200", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 200, + attempts: 1, + retriesExhausted: false, data: { stats: [ { source: "spotify", - data: { - history: [ - { date: "2025-01-01", streams_total: 1008736324 }, - { date: "2026-01-01", streams_total: 1330251464 }, - ], - }, + data: { history: [{ date: "2025-01-01", streams_total: 1008736324 }] }, }, ], }, @@ -45,10 +45,11 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); - expect(fetchSongstats).toHaveBeenCalledWith("tracks/historic_stats", { + expect(fetchSongstatsWithBackoff).toHaveBeenCalledWith("tracks/historic_stats", { isrc: "USA2P2015959", source: "spotify", }); + // exact transformation: each history point → a permanent songstats measurement expect(upsertSongMeasurements).toHaveBeenCalledWith([ { song: "USA2P2015959", @@ -59,74 +60,65 @@ describe("backfillTrackStep", () => { data_source: "songstats", raw_ref: "songstats-backfill", }, - { - song: "USA2P2015959", - platform: "spotify", - metric: "platform_displayed_play_count", - value: 1330251464, - captured_at: "2026-01-01T00:00:00.000Z", - data_source: "songstats", - raw_ref: "songstats-backfill", - }, ]); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, purpose: "backfill USA2P2015959", }); - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); expect(result).toEqual({ ok: true, hitsSpent: 1 }); }); - it("marks a transient upstream error (429) as failed (reclaimable) and records the spend", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 429, data: {} }); - - const result = await backfillTrackStep(ROW); - - // transient -> 'failed' so the daily reclaim sweep returns it to 'pending' - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "failed" }); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959 (failed 429)", + it("DEFERS (pending, no quota hit, signals stop) when backoff is exhausted on 429", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ + status: 429, + attempts: 6, + retriesExhausted: true, + data: {}, }); - expect(upsertSongMeasurements).not.toHaveBeenCalled(); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); - }); - - it("marks a transient 5xx as failed (reclaimable)", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 504, data: {} }); const result = await backfillTrackStep(ROW); - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "failed" }); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); + // left pending for the next drain; NO ledger hit (Songstats consumed nothing) + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "pending" }); + expect(insertSongstatsQuotaLedger).not.toHaveBeenCalled(); + expect(upsertSongMeasurements).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: false, hitsSpent: 0, deferred: true }); }); - it("marks a permanent client error (403) as done so reclaim never recycles it", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 403, data: {} }); + it("marks a definitive 404 (no history) as done and records the spend", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ + status: 404, + attempts: 1, + retriesExhausted: false, + data: {}, + }); const result = await backfillTrackStep(ROW); - // non-retryable 4xx (not 408/429) is terminal -> 'done', not 'failed' - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, - purpose: "backfill USA2P2015959 (terminal 403)", + purpose: "backfill USA2P2015959 (no data 404)", }); expect(result).toEqual({ ok: false, hitsSpent: 1 }); }); - it("marks a definitive 404 (no history exists) as done so it is never retried", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 404, data: {} }); + it("marks a permanent 4xx (403) as done (terminal) and records the spend", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ + status: 403, + attempts: 1, + retriesExhausted: false, + data: {}, + }); const result = await backfillTrackStep(ROW); - // terminal no-data -> 'done', not 'failed' — the reclaim sweep must not resurrect it - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, - purpose: "backfill USA2P2015959 (no data 404)", + purpose: "backfill USA2P2015959 (terminal 403)", }); - expect(upsertSongMeasurements).not.toHaveBeenCalled(); expect(result).toEqual({ ok: false, hitsSpent: 1 }); }); }); diff --git a/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts b/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts new file mode 100644 index 000000000..cc435558a --- /dev/null +++ b/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { songstatsBackfillWorkflow } from "../songstatsBackfillWorkflow"; + +import { getBackfillBudgetStep } from "../getBackfillBudgetStep"; +import { claimBackfillRowsStep } from "../claimBackfillRowsStep"; +import { backfillTrackStep } from "../backfillTrackStep"; +import { releaseClaimedRowsStep } from "../releaseClaimedRowsStep"; + +vi.mock("../getBackfillBudgetStep", () => ({ getBackfillBudgetStep: vi.fn() })); +vi.mock("../claimBackfillRowsStep", () => ({ claimBackfillRowsStep: vi.fn() })); +vi.mock("../backfillTrackStep", () => ({ backfillTrackStep: vi.fn() })); +vi.mock("../releaseClaimedRowsStep", () => ({ releaseClaimedRowsStep: vi.fn() })); + +const row = (id: string) => ({ id, song: id }) as never; + +describe("songstatsBackfillWorkflow", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(releaseClaimedRowsStep).mockResolvedValue(undefined); + }); + + it("releases the rest of the claimed batch to pending when a track defers", async () => { + vi.mocked(getBackfillBudgetStep).mockResolvedValue(100); + vi.mocked(claimBackfillRowsStep).mockResolvedValue([row("r1"), row("r2"), row("r3")]); + vi.mocked(backfillTrackStep) + .mockResolvedValueOnce({ ok: true, hitsSpent: 1 }) // r1 + .mockResolvedValueOnce({ ok: false, hitsSpent: 0, deferred: true }); // r2 defers + + const result = await songstatsBackfillWorkflow(); + + // r2 is set pending by the step itself; the unprocessed remainder (r3) is released here + expect(releaseClaimedRowsStep).toHaveBeenCalledWith(["r3"]); + expect(backfillTrackStep).toHaveBeenCalledTimes(2); // stopped at the defer, never reached r3 + expect(result).toEqual({ backfilled: 1, failed: 0, deferred: true }); + }); + + it("drains until the queue is empty and never releases when nothing defers", async () => { + vi.mocked(getBackfillBudgetStep).mockResolvedValue(100); + vi.mocked(claimBackfillRowsStep) + .mockResolvedValueOnce([row("a"), row("b")]) + .mockResolvedValueOnce([]); // queue drained + vi.mocked(backfillTrackStep) + .mockResolvedValueOnce({ ok: true, hitsSpent: 1 }) + .mockResolvedValueOnce({ ok: false, hitsSpent: 1 }); // terminal (e.g. 404) + + const result = await songstatsBackfillWorkflow(); + + expect(releaseClaimedRowsStep).not.toHaveBeenCalled(); + expect(result).toEqual({ backfilled: 1, failed: 1, deferred: false }); + }); + + it("does not drain when there is no budget", async () => { + vi.mocked(getBackfillBudgetStep).mockResolvedValue(0); + + const result = await songstatsBackfillWorkflow(); + + expect(claimBackfillRowsStep).not.toHaveBeenCalled(); + expect(result).toEqual({ backfilled: 0, failed: 0, deferred: false }); + }); +}); diff --git a/app/workflows/backfillTrackStep.ts b/app/workflows/backfillTrackStep.ts index 3247ff4cf..fd3947432 100644 --- a/app/workflows/backfillTrackStep.ts +++ b/app/workflows/backfillTrackStep.ts @@ -1,4 +1,4 @@ -import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; +import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; @@ -8,40 +8,48 @@ import { Tables } from "@/types/database.types"; const METRIC = "platform_displayed_play_count"; /** - * Backfill one claimed queue row: fetch the track's Songstats historic series - * (one quota hit — recorded win or lose), write each point as a permanent - * `songstats`-labeled measurement, and close the row. Failures mark the row - * failed without failing the run — the next row may still succeed. + * Backfill one claimed queue row, with bounded exponential backoff on Songstats' + * rate limit (Songstats is the rate authority — see chat#1797): + * - **200** → write each history point as a permanent `songstats` measurement, + * record the spend, mark `done`. + * - **404 / other 4xx** → a real request with a definitive answer; terminal, so + * mark `done` (404 = no history) and record the spend. + * - **backoff exhausted** (still 429/5xx after retries) → **defer**: leave the row + * `pending` for the next drain, consume no quota, and signal the workflow to + * stop (`deferred`) — Songstats is saturated right now. * * @param row - The claimed queue row (already in_progress) - * @returns ok + hits spent (always 1; the hit is consumed even on failure) + * @returns ok + hitsSpent (0 when deferred) + `deferred` when Songstats is saturated */ export async function backfillTrackStep( row: Tables<"songstats_backfill_queue">, -): Promise<{ ok: boolean; hitsSpent: number }> { +): Promise<{ ok: boolean; hitsSpent: number; deferred?: boolean }> { "use step"; - const result = await fetchSongstats("tracks/historic_stats", { + const result = await fetchSongstatsWithBackoff("tracks/historic_stats", { isrc: row.song, source: "spotify", }); - if (result.status !== 200) { - const status = result.status; - const isNoData = status === 404; - // Only transient errors are retryable: 408 (timeout), 429 (quota), any 5xx. - const isRetryable = status === 408 || status === 429 || status >= 500; - - // `failed` is reclaimable (the daily sweep returns it to `pending`, bounded - // by the rolling-window budget). 404 no-data and other permanent 4xx are - // terminal → `done`, so reclaim never recycles a track that can't succeed. - const nextStatus = isRetryable ? "failed" : "done"; - - let outcome = `terminal ${status}`; - if (isNoData) outcome = "no data 404"; - else if (isRetryable) outcome = `failed ${status}`; + if (result.retriesExhausted) { + // Still retryable (429 throttle / 408 / gateway 5xx) past the backoff bound — + // leave it for the next run, spend nothing. + console.log( + `[backfill] ${row.song} deferred (retryable ${result.status} after ${result.attempts} tries)`, + ); + await updateSongstatsBackfillQueue([row.id], { status: "pending" }); + return { ok: false, hitsSpent: 0, deferred: true }; + } - await insertSongstatsQuotaLedger({ hits: 1, purpose: `backfill ${row.song} (${outcome})` }); - await updateSongstatsBackfillQueue(row.id, { status: nextStatus }); + if (result.status !== 200) { + const noData = result.status === 404; + console.log( + `[backfill] ${row.song} done (${noData ? "no data 404" : `terminal ${result.status}`})`, + ); + await insertSongstatsQuotaLedger({ + hits: 1, + purpose: `backfill ${row.song} (${noData ? "no data 404" : `terminal ${result.status}`})`, + }); + await updateSongstatsBackfillQueue([row.id], { status: "done" }); return { ok: false, hitsSpent: 1 }; } @@ -67,7 +75,8 @@ export async function backfillTrackStep( }); await upsertSongMeasurements(rows); + console.log(`[backfill] ${row.song} done (${rows.length} points written)`); await insertSongstatsQuotaLedger({ hits: 1, purpose: `backfill ${row.song}` }); - await updateSongstatsBackfillQueue(row.id, { status: "done" }); + await updateSongstatsBackfillQueue([row.id], { status: "done" }); return { ok: true, hitsSpent: 1 }; } diff --git a/app/workflows/releaseClaimedRowsStep.ts b/app/workflows/releaseClaimedRowsStep.ts new file mode 100644 index 000000000..788d752a0 --- /dev/null +++ b/app/workflows/releaseClaimedRowsStep.ts @@ -0,0 +1,15 @@ +import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; + +/** + * Durable step: return unprocessed claimed rows to `pending` when the drain + * stops early on a defer, so the next run retries them immediately instead of + * waiting on the stale-reclaim sweep. + * + * @param ids - Queue row ids still `in_progress` from the aborted batch. + */ +export async function releaseClaimedRowsStep(ids: string[]): Promise { + "use step"; + if (ids.length === 0) return; + await updateSongstatsBackfillQueue(ids, { status: "pending" }); + console.log(`[songstats-backfill] released ${ids.length} claimed rows back to pending`); +} diff --git a/app/workflows/songstatsBackfillWorkflow.ts b/app/workflows/songstatsBackfillWorkflow.ts index 0a1960bf9..f94c0223d 100644 --- a/app/workflows/songstatsBackfillWorkflow.ts +++ b/app/workflows/songstatsBackfillWorkflow.ts @@ -1,15 +1,20 @@ import { getBackfillBudgetStep } from "@/app/workflows/getBackfillBudgetStep"; import { claimBackfillRowsStep } from "@/app/workflows/claimBackfillRowsStep"; import { backfillTrackStep } from "@/app/workflows/backfillTrackStep"; +import { releaseClaimedRowsStep } from "@/app/workflows/releaseClaimedRowsStep"; const BATCH_SIZE = 25; /** - * Durable Songstats backfill drain (recoupable/chat#1791 write path): check - * the rolling-window budget, claim value-ranked rows via the SKIP LOCKED RPC, - * backfill each track's historic series into the measurement store, and stop - * when the queue or the budget is dry. Every quota hit converts into - * permanent owned data (fetch-once: captured history is never refetched). + * Durable Songstats backfill drain (recoupable/chat#1791 write path): claim + * value-ranked rows via the SKIP LOCKED RPC and backfill each track's historic + * series, with per-track exponential backoff handling Songstats' rate limit + * (chat#1797). **Stops as soon as a track defers** — Songstats still + * rate-limiting it past the backoff bound — releasing the rest of the claimed + * batch back to `pending` (so the next drain retries them immediately rather + * than waiting on stale-reclaim) instead of hammering a saturated API. Every + * successful hit converts into permanent owned data (fetch-once: captured + * history is never refetched). */ export async function songstatsBackfillWorkflow() { "use workflow"; @@ -17,13 +22,23 @@ export async function songstatsBackfillWorkflow() { let budget = await getBackfillBudgetStep(); let backfilled = 0; let failed = 0; + let deferred = false; - while (budget > 0) { + drain: while (budget > 0) { const rows = await claimBackfillRowsStep(Math.min(budget, BATCH_SIZE)); if (rows.length === 0) break; + console.log(`[songstats-backfill] claimed ${rows.length} rows`); - for (const row of rows) { - const result = await backfillTrackStep(row); + for (let i = 0; i < rows.length; i += 1) { + const result = await backfillTrackStep(rows[i]); + if (result.deferred) { + // Songstats is saturated — stop now. The deferred row is already back to + // `pending`; release the rest of this claimed batch too so they don't sit + // `in_progress` until stale-reclaim. + deferred = true; + await releaseClaimedRowsStep(rows.slice(i + 1).map(r => r.id)); + break drain; + } budget -= result.hitsSpent; if (result.ok) backfilled += 1; else failed += 1; @@ -31,6 +46,9 @@ export async function songstatsBackfillWorkflow() { } } - console.log(`[songstats-backfill] done: ${backfilled} backfilled, ${failed} failed`); - return { backfilled, failed }; + console.log( + `[songstats-backfill] done: ${backfilled} backfilled, ${failed} terminal` + + (deferred ? ", deferred (rate-limited)" : ""), + ); + return { backfilled, failed, deferred }; } diff --git a/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts b/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts new file mode 100644 index 000000000..21015ef14 --- /dev/null +++ b/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchSongstatsWithBackoff } from "../fetchSongstatsWithBackoff"; +import { fetchSongstats } from "../fetchSongstats"; + +vi.mock("../fetchSongstats", () => ({ fetchSongstats: vi.fn() })); + +const noSleep = vi.fn().mockResolvedValue(undefined); + +describe("fetchSongstatsWithBackoff", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns immediately on 200 with no retries or sleeps", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 200, data: { ok: true } }); + + const r = await fetchSongstatsWithBackoff( + "tracks/historic_stats", + { isrc: "I" }, + { sleep: noSleep }, + ); + + expect(fetchSongstats).toHaveBeenCalledTimes(1); + expect(noSleep).not.toHaveBeenCalled(); + expect(r).toMatchObject({ status: 200, attempts: 1, retriesExhausted: false }); + }); + + it("does NOT retry a non-retryable status (404)", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 404, data: {} }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep }); + + expect(fetchSongstats).toHaveBeenCalledTimes(1); + expect(r).toMatchObject({ status: 404, retriesExhausted: false }); + }); + + it("backs off and retries on 429, succeeding on a later attempt", async () => { + vi.mocked(fetchSongstats) + .mockResolvedValueOnce({ status: 429, data: {} }) + .mockResolvedValueOnce({ status: 429, data: {} }) + .mockResolvedValueOnce({ status: 200, data: { ok: true } }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep, baseMs: 100 }); + + expect(fetchSongstats).toHaveBeenCalledTimes(3); + expect(noSleep).toHaveBeenCalledTimes(2); + // exponential: 100 then 200 + expect(noSleep).toHaveBeenNthCalledWith(1, 100); + expect(noSleep).toHaveBeenNthCalledWith(2, 200); + expect(r).toMatchObject({ status: 200, attempts: 3, retriesExhausted: false }); + }); + + it("gives up after maxRetries on persistent 429 and flags retriesExhausted", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 429, data: {} }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep, maxRetries: 3 }); + + expect(fetchSongstats).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + expect(noSleep).toHaveBeenCalledTimes(3); + expect(r).toMatchObject({ status: 429, retriesExhausted: true }); + }); + + it("treats transient gateway 5xx (503) and 408 as retryable", async () => { + vi.mocked(fetchSongstats) + .mockResolvedValueOnce({ status: 503, data: {} }) + .mockResolvedValueOnce({ status: 200, data: {} }); + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep, maxRetries: 2 }); + expect(fetchSongstats).toHaveBeenCalledTimes(2); + expect(r).toMatchObject({ status: 200, retriesExhausted: false }); + }); + + it("does NOT retry a 500 (fetchSongstats maps missing key / fetch failure to 500)", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 500, data: {} }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep }); + + expect(fetchSongstats).toHaveBeenCalledTimes(1); + expect(noSleep).not.toHaveBeenCalled(); + expect(r).toMatchObject({ status: 500, retriesExhausted: false }); + }); + + it("caps the backoff at maxMs", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 429, data: {} }); + await fetchSongstatsWithBackoff("p", undefined, { + sleep: noSleep, + baseMs: 1000, + maxMs: 1500, + maxRetries: 3, + }); + // 1000, 2000->capped 1500, 4000->capped 1500 + expect(noSleep.mock.calls.map(c => c[0])).toEqual([1000, 1500, 1500]); + }); +}); diff --git a/lib/songstats/__tests__/isRetryableStatus.test.ts b/lib/songstats/__tests__/isRetryableStatus.test.ts new file mode 100644 index 000000000..a1b707f09 --- /dev/null +++ b/lib/songstats/__tests__/isRetryableStatus.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { isRetryableStatus } from "../isRetryableStatus"; + +describe("isRetryableStatus", () => { + it("retries transient throttling/timeouts: 408, 429", () => { + expect(isRetryableStatus(408)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + }); + + it("retries transient gateway 5xx: 502, 503, 504", () => { + expect(isRetryableStatus(502)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(504)).toBe(true); + }); + + it("does NOT retry 500/501 (fetchSongstats maps missing key + fetch failures to 500)", () => { + expect(isRetryableStatus(500)).toBe(false); + expect(isRetryableStatus(501)).toBe(false); + }); + + it("does NOT retry definitive responses: 200, 404, 403", () => { + expect(isRetryableStatus(200)).toBe(false); + expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(403)).toBe(false); + }); +}); diff --git a/lib/songstats/fetchSongstatsWithBackoff.ts b/lib/songstats/fetchSongstatsWithBackoff.ts new file mode 100644 index 000000000..8b3a4b097 --- /dev/null +++ b/lib/songstats/fetchSongstatsWithBackoff.ts @@ -0,0 +1,61 @@ +import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; +import { isRetryableStatus } from "@/lib/songstats/isRetryableStatus"; +import { delay } from "@/lib/time/delay"; +import type { ProxyResult } from "@/lib/research/ProxyResult"; + +// Short, in-step backoff for Songstats' per-second rate limit. The default +// budget is 1+2+4+8 = 15s of waits (base 1s, doubling, capped at 8s, 4 retries), +// well within a workflow step's duration. Persistent rejection defers the row to +// the next drain run rather than sleeping for minutes inside one invocation. +const DEFAULT_MAX_RETRIES = 4; +const DEFAULT_BASE_MS = 1000; +const DEFAULT_MAX_MS = 8_000; + +export type FetchSongstatsBackoffOptions = { + maxRetries?: number; + baseMs?: number; + maxMs?: number; + /** Injectable for tests; defaults to a real timer. */ + sleep?: (ms: number) => Promise; +}; + +export type BackoffResult = ProxyResult & { + /** Total attempts made (1 + retries). */ + attempts: number; + /** True when the final result is still retryable after exhausting retries. */ + retriesExhausted: boolean; +}; + +/** + * Call Songstats with bounded exponential backoff on transient rejections + * (429 rate limit, 408, 5xx). Retries the **same** request up to `maxRetries` + * with `min(maxMs, baseMs * 2^attempt)` waits; a 200 or any non-retryable status + * (e.g. 404) returns immediately. When backoff is exhausted and the call is + * still being rejected, `retriesExhausted` is true so the caller can defer the + * work (leave it `pending`) rather than burn it — Songstats is the rate + * authority, not a local quota ledger. + * + * @param path - Songstats enterprise path (e.g. `tracks/historic_stats`). + * @param queryParams - Query params forwarded to Songstats. + * @param options - Backoff tuning + an injectable `sleep`. + */ +export async function fetchSongstatsWithBackoff( + path: string, + queryParams?: Record, + options: FetchSongstatsBackoffOptions = {}, +): Promise { + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const baseMs = options.baseMs ?? DEFAULT_BASE_MS; + const maxMs = options.maxMs ?? DEFAULT_MAX_MS; + const sleep = options.sleep ?? delay; + + let result = await fetchSongstats(path, queryParams); + let retries = 0; + while (isRetryableStatus(result.status) && retries < maxRetries) { + await sleep(Math.min(maxMs, baseMs * 2 ** retries)); + result = await fetchSongstats(path, queryParams); + retries += 1; + } + + return { ...result, attempts: retries + 1, retriesExhausted: isRetryableStatus(result.status) }; +} diff --git a/lib/songstats/isRetryableStatus.ts b/lib/songstats/isRetryableStatus.ts new file mode 100644 index 000000000..eb0004ee3 --- /dev/null +++ b/lib/songstats/isRetryableStatus.ts @@ -0,0 +1,14 @@ +/** + * Whether a Songstats response status is worth retrying with backoff. + * + * Transient: 408 (request timeout), 429 (rate limit), and the gateway 5xx + * 502/503/504 (bad gateway / unavailable / gateway timeout). Deliberately + * **excludes** 500/501 — `fetchSongstats` maps a missing API key and any + * non-HTTP fetch failure to 500, which are permanent for that request, so + * retrying them just burns the backoff budget before the row defers. + * + * @param status - HTTP status from `fetchSongstats`. + */ +export function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || status === 502 || status === 503 || status === 504; +} diff --git a/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts b/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts index eb8d02300..73be73472 100644 --- a/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts +++ b/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts @@ -14,24 +14,40 @@ vi.mock("../../serverClient", () => { describe("updateSongstatsBackfillQueue", () => { beforeEach(() => vi.clearAllMocks()); - it("updates a queue row's status by id", async () => { - const eq = vi.fn().mockResolvedValue({ error: null }); - const update = vi.fn().mockReturnValue({ eq }); + it("updates a single row by id (one-element array)", async () => { + const inFn = vi.fn().mockResolvedValue({ error: null }); + const update = vi.fn().mockReturnValue({ in: inFn }); vi.mocked(supabase.from).mockReturnValue({ update } as never); - await updateSongstatsBackfillQueue("q1", { status: "done" }); + await updateSongstatsBackfillQueue(["q1"], { status: "done" }); expect(supabase.from).toHaveBeenCalledWith("songstats_backfill_queue"); expect(update).toHaveBeenCalledWith({ status: "done" }); - expect(eq).toHaveBeenCalledWith("id", "q1"); + expect(inFn).toHaveBeenCalledWith("id", ["q1"]); + }); + + it("bulk-updates many rows in one call (e.g. releasing a claimed batch)", async () => { + const inFn = vi.fn().mockResolvedValue({ error: null }); + const update = vi.fn().mockReturnValue({ in: inFn }); + vi.mocked(supabase.from).mockReturnValue({ update } as never); + + await updateSongstatsBackfillQueue(["q1", "q2"], { status: "pending" }); + + expect(update).toHaveBeenCalledWith({ status: "pending" }); + expect(inFn).toHaveBeenCalledWith("id", ["q1", "q2"]); + }); + + it("is a no-op on an empty id list (no DB call)", async () => { + await updateSongstatsBackfillQueue([], { status: "pending" }); + expect(supabase.from).not.toHaveBeenCalled(); }); it("throws on update error", async () => { - const eq = vi.fn().mockResolvedValue({ error: { message: "boom" } }); - const update = vi.fn().mockReturnValue({ eq }); + const inFn = vi.fn().mockResolvedValue({ error: { message: "boom" } }); + const update = vi.fn().mockReturnValue({ in: inFn }); vi.mocked(supabase.from).mockReturnValue({ update } as never); - await expect(updateSongstatsBackfillQueue("q1", { status: "failed" })).rejects.toThrow( + await expect(updateSongstatsBackfillQueue(["q1"], { status: "failed" })).rejects.toThrow( "Failed to update songstats backfill queue: boom", ); }); diff --git a/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts b/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts index b462381ab..d2469412d 100644 --- a/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts +++ b/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts @@ -2,17 +2,22 @@ import supabase from "../serverClient"; import { TablesUpdate } from "@/types/database.types"; /** - * Update a backfill queue row (mark done/failed after a claim). + * Update one or more backfill queue rows by id in a single round trip. Handles + * both the per-row status flip after a claim (`[row.id]`) and the bulk release + * of a claimed batch back to `pending` when the drain stops early (`.in` works + * for one id or many). No-op on an empty id list. * - * @param id - The queue row id - * @param fields - Fields to update + * @param ids - Queue row ids to update + * @param fields - Fields to set (e.g. `{ status: "done" }`) * @throws Error if the update fails */ export async function updateSongstatsBackfillQueue( - id: string, + ids: string[], fields: TablesUpdate<"songstats_backfill_queue">, ): Promise { - const { error } = await supabase.from("songstats_backfill_queue").update(fields).eq("id", id); + if (ids.length === 0) return; + + const { error } = await supabase.from("songstats_backfill_queue").update(fields).in("id", ids); if (error) { throw new Error(`Failed to update songstats backfill queue: ${error.message}`); From 5f459463996e1139a40743eaf5fd83c54f4ed04a Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 16 Jun 2026 17:14:31 -0500 Subject: [PATCH 03/27] refactor(songstats): remove local quota ledger + budget gate (chat#1797) (#674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bullet 2 of chat#1797 (code half). Songstats is the rate authority — removes getBackfillBudgetStep, the budget gate, and insertSongstatsQuotaLedger/selectSongstatsQuotaSpent. The drain now claims+processes regardless of the ledger (un-stalls the backfill); the songstats_quota_ledger table is dropped in recoupable/database#35 (apply AFTER this deploys). --- .../__tests__/backfillTrackStep.test.ts | 34 +++++------------- .../__tests__/getBackfillBudgetStep.test.ts | 32 ----------------- .../songstatsBackfillWorkflow.test.ts | 24 ++++++------- app/workflows/backfillTrackStep.ts | 29 +++++++-------- app/workflows/getBackfillBudgetStep.ts | 21 ----------- app/workflows/songstatsBackfillWorkflow.ts | 29 +++++++-------- .../insertSongstatsQuotaLedger.test.ts | 32 ----------------- .../selectSongstatsQuotaSpent.test.ts | 35 ------------------- .../insertSongstatsQuotaLedger.ts | 19 ---------- .../selectSongstatsQuotaSpent.ts | 23 ------------ 10 files changed, 42 insertions(+), 236 deletions(-) delete mode 100644 app/workflows/__tests__/getBackfillBudgetStep.test.ts delete mode 100644 app/workflows/getBackfillBudgetStep.ts delete mode 100644 lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts delete mode 100644 lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts delete mode 100644 lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts delete mode 100644 lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts diff --git a/app/workflows/__tests__/backfillTrackStep.test.ts b/app/workflows/__tests__/backfillTrackStep.test.ts index 6c211ed61..c923cb030 100644 --- a/app/workflows/__tests__/backfillTrackStep.test.ts +++ b/app/workflows/__tests__/backfillTrackStep.test.ts @@ -3,7 +3,6 @@ import { backfillTrackStep } from "../backfillTrackStep"; import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; -import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; vi.mock("@/lib/songstats/fetchSongstatsWithBackoff", () => ({ @@ -12,9 +11,6 @@ vi.mock("@/lib/songstats/fetchSongstatsWithBackoff", () => ({ vi.mock("@/lib/supabase/song_measurements/upsertSongMeasurements", () => ({ upsertSongMeasurements: vi.fn(), })); -vi.mock("@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger", () => ({ - insertSongstatsQuotaLedger: vi.fn(), -})); vi.mock("@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue", () => ({ updateSongstatsBackfillQueue: vi.fn(), })); @@ -28,7 +24,7 @@ describe("backfillTrackStep", () => { vi.mocked(upsertSongMeasurements).mockResolvedValue([] as never); }); - it("writes the historic series, records the spend, marks done on 200", async () => { + it("writes the historic series and marks done on 200", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 200, attempts: 1, @@ -61,15 +57,11 @@ describe("backfillTrackStep", () => { raw_ref: "songstats-backfill", }, ]); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959", - }); expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); - expect(result).toEqual({ ok: true, hitsSpent: 1 }); + expect(result).toEqual({ ok: true }); }); - it("DEFERS (pending, no quota hit, signals stop) when backoff is exhausted on 429", async () => { + it("DEFERS (pending, signals stop) when backoff is exhausted on 429", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 429, attempts: 6, @@ -79,14 +71,12 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); - // left pending for the next drain; NO ledger hit (Songstats consumed nothing) expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "pending" }); - expect(insertSongstatsQuotaLedger).not.toHaveBeenCalled(); expect(upsertSongMeasurements).not.toHaveBeenCalled(); - expect(result).toEqual({ ok: false, hitsSpent: 0, deferred: true }); + expect(result).toEqual({ ok: false, deferred: true }); }); - it("marks a definitive 404 (no history) as done and records the spend", async () => { + it("marks a definitive 404 (no history) as done", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 404, attempts: 1, @@ -97,14 +87,10 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959 (no data 404)", - }); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); + expect(result).toEqual({ ok: false }); }); - it("marks a permanent 4xx (403) as done (terminal) and records the spend", async () => { + it("marks a permanent 4xx (403) as done (terminal)", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 403, attempts: 1, @@ -115,10 +101,6 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959 (terminal 403)", - }); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); + expect(result).toEqual({ ok: false }); }); }); diff --git a/app/workflows/__tests__/getBackfillBudgetStep.test.ts b/app/workflows/__tests__/getBackfillBudgetStep.test.ts deleted file mode 100644 index 6c3fb8159..000000000 --- a/app/workflows/__tests__/getBackfillBudgetStep.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getBackfillBudgetStep } from "../getBackfillBudgetStep"; - -import { selectSongstatsQuotaSpent } from "@/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent"; - -vi.mock("@/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent", () => ({ - selectSongstatsQuotaSpent: vi.fn(), -})); - -describe("getBackfillBudgetStep", () => { - beforeEach(() => { - vi.clearAllMocks(); - delete process.env.SONGSTATS_QUOTA_LIMIT; - delete process.env.SONGSTATS_QUOTA_RESERVE; - }); - - it("computes limit - reserve - spent over the rolling 30d window", async () => { - vi.mocked(selectSongstatsQuotaSpent).mockResolvedValue(300); - - const budget = await getBackfillBudgetStep(); - - expect(budget).toBe(1000 - 100 - 300); - const since = vi.mocked(selectSongstatsQuotaSpent).mock.calls[0][0]; - const ageDays = (Date.now() - new Date(since).getTime()) / 86400000; - expect(Math.round(ageDays)).toBe(30); - }); - - it("never returns negative", async () => { - vi.mocked(selectSongstatsQuotaSpent).mockResolvedValue(5000); - expect(await getBackfillBudgetStep()).toBe(0); - }); -}); diff --git a/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts b/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts index cc435558a..b18c5aeb7 100644 --- a/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts +++ b/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts @@ -1,12 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { songstatsBackfillWorkflow } from "../songstatsBackfillWorkflow"; -import { getBackfillBudgetStep } from "../getBackfillBudgetStep"; import { claimBackfillRowsStep } from "../claimBackfillRowsStep"; import { backfillTrackStep } from "../backfillTrackStep"; import { releaseClaimedRowsStep } from "../releaseClaimedRowsStep"; -vi.mock("../getBackfillBudgetStep", () => ({ getBackfillBudgetStep: vi.fn() })); vi.mock("../claimBackfillRowsStep", () => ({ claimBackfillRowsStep: vi.fn() })); vi.mock("../backfillTrackStep", () => ({ backfillTrackStep: vi.fn() })); vi.mock("../releaseClaimedRowsStep", () => ({ releaseClaimedRowsStep: vi.fn() })); @@ -21,41 +19,39 @@ describe("songstatsBackfillWorkflow", () => { }); it("releases the rest of the claimed batch to pending when a track defers", async () => { - vi.mocked(getBackfillBudgetStep).mockResolvedValue(100); vi.mocked(claimBackfillRowsStep).mockResolvedValue([row("r1"), row("r2"), row("r3")]); vi.mocked(backfillTrackStep) - .mockResolvedValueOnce({ ok: true, hitsSpent: 1 }) // r1 - .mockResolvedValueOnce({ ok: false, hitsSpent: 0, deferred: true }); // r2 defers + .mockResolvedValueOnce({ ok: true }) // r1 + .mockResolvedValueOnce({ ok: false, deferred: true }); // r2 defers const result = await songstatsBackfillWorkflow(); // r2 is set pending by the step itself; the unprocessed remainder (r3) is released here expect(releaseClaimedRowsStep).toHaveBeenCalledWith(["r3"]); expect(backfillTrackStep).toHaveBeenCalledTimes(2); // stopped at the defer, never reached r3 - expect(result).toEqual({ backfilled: 1, failed: 0, deferred: true }); + expect(result).toEqual({ backfilled: 1, terminal: 0, deferred: true }); }); it("drains until the queue is empty and never releases when nothing defers", async () => { - vi.mocked(getBackfillBudgetStep).mockResolvedValue(100); vi.mocked(claimBackfillRowsStep) .mockResolvedValueOnce([row("a"), row("b")]) .mockResolvedValueOnce([]); // queue drained vi.mocked(backfillTrackStep) - .mockResolvedValueOnce({ ok: true, hitsSpent: 1 }) - .mockResolvedValueOnce({ ok: false, hitsSpent: 1 }); // terminal (e.g. 404) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: false }); // terminal (e.g. 404) const result = await songstatsBackfillWorkflow(); expect(releaseClaimedRowsStep).not.toHaveBeenCalled(); - expect(result).toEqual({ backfilled: 1, failed: 1, deferred: false }); + expect(result).toEqual({ backfilled: 1, terminal: 1, deferred: false }); }); - it("does not drain when there is no budget", async () => { - vi.mocked(getBackfillBudgetStep).mockResolvedValue(0); + it("stops immediately when the first claim is empty", async () => { + vi.mocked(claimBackfillRowsStep).mockResolvedValue([]); const result = await songstatsBackfillWorkflow(); - expect(claimBackfillRowsStep).not.toHaveBeenCalled(); - expect(result).toEqual({ backfilled: 0, failed: 0, deferred: false }); + expect(backfillTrackStep).not.toHaveBeenCalled(); + expect(result).toEqual({ backfilled: 0, terminal: 0, deferred: false }); }); }); diff --git a/app/workflows/backfillTrackStep.ts b/app/workflows/backfillTrackStep.ts index fd3947432..4a627ee88 100644 --- a/app/workflows/backfillTrackStep.ts +++ b/app/workflows/backfillTrackStep.ts @@ -1,6 +1,5 @@ import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; -import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; import { historicStatsPayloadSchema } from "@/lib/research/playcounts/historicStatsPayloadSchema"; import { Tables } from "@/types/database.types"; @@ -9,21 +8,22 @@ const METRIC = "platform_displayed_play_count"; /** * Backfill one claimed queue row, with bounded exponential backoff on Songstats' - * rate limit (Songstats is the rate authority — see chat#1797): + * rate limit (Songstats is the rate authority — see chat#1797; there is no local + * quota ledger): * - **200** → write each history point as a permanent `songstats` measurement, - * record the spend, mark `done`. + * mark `done`. * - **404 / other 4xx** → a real request with a definitive answer; terminal, so - * mark `done` (404 = no history) and record the spend. + * mark `done` (404 = no history). * - **backoff exhausted** (still 429/5xx after retries) → **defer**: leave the row - * `pending` for the next drain, consume no quota, and signal the workflow to - * stop (`deferred`) — Songstats is saturated right now. + * `pending` for the next drain and signal the workflow to stop (`deferred`) — + * Songstats is saturated right now. * * @param row - The claimed queue row (already in_progress) - * @returns ok + hitsSpent (0 when deferred) + `deferred` when Songstats is saturated + * @returns `ok` (true on a written backfill) + `deferred` when Songstats is saturated */ export async function backfillTrackStep( row: Tables<"songstats_backfill_queue">, -): Promise<{ ok: boolean; hitsSpent: number; deferred?: boolean }> { +): Promise<{ ok: boolean; deferred?: boolean }> { "use step"; const result = await fetchSongstatsWithBackoff("tracks/historic_stats", { isrc: row.song, @@ -32,12 +32,12 @@ export async function backfillTrackStep( if (result.retriesExhausted) { // Still retryable (429 throttle / 408 / gateway 5xx) past the backoff bound — - // leave it for the next run, spend nothing. + // leave it for the next run. console.log( `[backfill] ${row.song} deferred (retryable ${result.status} after ${result.attempts} tries)`, ); await updateSongstatsBackfillQueue([row.id], { status: "pending" }); - return { ok: false, hitsSpent: 0, deferred: true }; + return { ok: false, deferred: true }; } if (result.status !== 200) { @@ -45,12 +45,8 @@ export async function backfillTrackStep( console.log( `[backfill] ${row.song} done (${noData ? "no data 404" : `terminal ${result.status}`})`, ); - await insertSongstatsQuotaLedger({ - hits: 1, - purpose: `backfill ${row.song} (${noData ? "no data 404" : `terminal ${result.status}`})`, - }); await updateSongstatsBackfillQueue([row.id], { status: "done" }); - return { ok: false, hitsSpent: 1 }; + return { ok: false }; } const parsed = historicStatsPayloadSchema.safeParse(result.data); @@ -76,7 +72,6 @@ export async function backfillTrackStep( await upsertSongMeasurements(rows); console.log(`[backfill] ${row.song} done (${rows.length} points written)`); - await insertSongstatsQuotaLedger({ hits: 1, purpose: `backfill ${row.song}` }); await updateSongstatsBackfillQueue([row.id], { status: "done" }); - return { ok: true, hitsSpent: 1 }; + return { ok: true }; } diff --git a/app/workflows/getBackfillBudgetStep.ts b/app/workflows/getBackfillBudgetStep.ts deleted file mode 100644 index 866ee7605..000000000 --- a/app/workflows/getBackfillBudgetStep.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { selectSongstatsQuotaSpent } from "@/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent"; - -const DEFAULT_QUOTA_LIMIT = 1000; -const DEFAULT_QUOTA_RESERVE = 100; -const WINDOW_DAYS = 30; - -/** - * Remaining Songstats budget for this run: plan limit minus a reserve (kept - * for request-path fallbacks and manual research) minus hits spent in the - * rolling 30-day window. Never negative. - * - * @returns Hits the backfill worker may spend now - */ -export async function getBackfillBudgetStep(): Promise { - "use step"; - const limit = Number(process.env.SONGSTATS_QUOTA_LIMIT) || DEFAULT_QUOTA_LIMIT; - const reserve = Number(process.env.SONGSTATS_QUOTA_RESERVE) || DEFAULT_QUOTA_RESERVE; - const since = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString(); - const spent = await selectSongstatsQuotaSpent(since); - return Math.max(0, limit - reserve - spent); -} diff --git a/app/workflows/songstatsBackfillWorkflow.ts b/app/workflows/songstatsBackfillWorkflow.ts index f94c0223d..459d18870 100644 --- a/app/workflows/songstatsBackfillWorkflow.ts +++ b/app/workflows/songstatsBackfillWorkflow.ts @@ -1,4 +1,3 @@ -import { getBackfillBudgetStep } from "@/app/workflows/getBackfillBudgetStep"; import { claimBackfillRowsStep } from "@/app/workflows/claimBackfillRowsStep"; import { backfillTrackStep } from "@/app/workflows/backfillTrackStep"; import { releaseClaimedRowsStep } from "@/app/workflows/releaseClaimedRowsStep"; @@ -8,24 +7,22 @@ const BATCH_SIZE = 25; /** * Durable Songstats backfill drain (recoupable/chat#1791 write path): claim * value-ranked rows via the SKIP LOCKED RPC and backfill each track's historic - * series, with per-track exponential backoff handling Songstats' rate limit - * (chat#1797). **Stops as soon as a track defers** — Songstats still - * rate-limiting it past the backoff bound — releasing the rest of the claimed - * batch back to `pending` (so the next drain retries them immediately rather - * than waiting on stale-reclaim) instead of hammering a saturated API. Every - * successful hit converts into permanent owned data (fetch-once: captured - * history is never refetched). + * series. Per-track exponential backoff absorbs Songstats' rate limit; the run + * **stops as soon as a track defers** (still retryable past the backoff bound), + * releasing the rest of the claimed batch back to `pending` so the next drain + * retries them immediately rather than waiting on stale-reclaim. Otherwise it + * drains until the queue has no claimable `pending` rows. Every backfill + * converts into permanent owned data (fetch-once). */ export async function songstatsBackfillWorkflow() { "use workflow"; - let budget = await getBackfillBudgetStep(); let backfilled = 0; - let failed = 0; + let terminal = 0; let deferred = false; - drain: while (budget > 0) { - const rows = await claimBackfillRowsStep(Math.min(budget, BATCH_SIZE)); + drain: while (true) { + const rows = await claimBackfillRowsStep(BATCH_SIZE); if (rows.length === 0) break; console.log(`[songstats-backfill] claimed ${rows.length} rows`); @@ -39,16 +36,14 @@ export async function songstatsBackfillWorkflow() { await releaseClaimedRowsStep(rows.slice(i + 1).map(r => r.id)); break drain; } - budget -= result.hitsSpent; if (result.ok) backfilled += 1; - else failed += 1; - if (budget <= 0) break; + else terminal += 1; } } console.log( - `[songstats-backfill] done: ${backfilled} backfilled, ${failed} terminal` + + `[songstats-backfill] done: ${backfilled} backfilled, ${terminal} terminal` + (deferred ? ", deferred (rate-limited)" : ""), ); - return { backfilled, failed, deferred }; + return { backfilled, terminal, deferred }; } diff --git a/lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts b/lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts deleted file mode 100644 index 4f7d5c2a8..000000000 --- a/lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { insertSongstatsQuotaLedger } from "../insertSongstatsQuotaLedger"; -import supabase from "../../serverClient"; - -vi.mock("../../serverClient", () => { - const mockFrom = vi.fn(); - const mockRpc = vi.fn(); - return { default: { from: mockFrom, rpc: mockRpc } }; -}); - -describe("insertSongstatsQuotaLedger", () => { - beforeEach(() => vi.clearAllMocks()); - - it("records spent hits", async () => { - const insert = vi.fn().mockResolvedValue({ error: null }); - vi.mocked(supabase.from).mockReturnValue({ insert } as never); - - await insertSongstatsQuotaLedger({ hits: 1, purpose: "backfill USA2P2015959" }); - - expect(supabase.from).toHaveBeenCalledWith("songstats_quota_ledger"); - expect(insert).toHaveBeenCalledWith([{ hits: 1, purpose: "backfill USA2P2015959" }]); - }); - - it("throws on insert error (spend must be recorded)", async () => { - const insert = vi.fn().mockResolvedValue({ error: { message: "boom" } }); - vi.mocked(supabase.from).mockReturnValue({ insert } as never); - - await expect(insertSongstatsQuotaLedger({ hits: 1 })).rejects.toThrow( - "Failed to record songstats quota spend: boom", - ); - }); -}); diff --git a/lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts b/lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts deleted file mode 100644 index 6d59c6267..000000000 --- a/lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { selectSongstatsQuotaSpent } from "../selectSongstatsQuotaSpent"; -import supabase from "../../serverClient"; - -vi.mock("../../serverClient", () => { - const mockFrom = vi.fn(); - const mockRpc = vi.fn(); - return { default: { from: mockFrom, rpc: mockRpc } }; -}); - -describe("selectSongstatsQuotaSpent", () => { - beforeEach(() => vi.clearAllMocks()); - - it("sums hits spent since the window start", async () => { - const gte = vi.fn().mockResolvedValue({ data: [{ hits: 3 }, { hits: 7 }], error: null }); - const select = vi.fn().mockReturnValue({ gte }); - vi.mocked(supabase.from).mockReturnValue({ select } as never); - - const result = await selectSongstatsQuotaSpent("2026-05-12T00:00:00Z"); - - expect(supabase.from).toHaveBeenCalledWith("songstats_quota_ledger"); - expect(gte).toHaveBeenCalledWith("spent_at", "2026-05-12T00:00:00Z"); - expect(result).toBe(10); - }); - - it("treats errors as full quota spent (fail safe — do not overspend)", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const gte = vi.fn().mockResolvedValue({ data: null, error: { message: "boom" } }); - const select = vi.fn().mockReturnValue({ gte }); - vi.mocked(supabase.from).mockReturnValue({ select } as never); - - expect(await selectSongstatsQuotaSpent("2026-05-12T00:00:00Z")).toBe(Number.MAX_SAFE_INTEGER); - consoleError.mockRestore(); - }); -}); diff --git a/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts b/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts deleted file mode 100644 index 777c42a79..000000000 --- a/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts +++ /dev/null @@ -1,19 +0,0 @@ -import supabase from "../serverClient"; -import { TablesInsert } from "@/types/database.types"; - -/** - * Record Songstats quota spend. Throws on failure — unrecorded spend would - * let the worker silently blow the rolling-window budget. - * - * @param entry - hits spent, optional purpose/account attribution - * @throws Error if the insert fails - */ -export async function insertSongstatsQuotaLedger( - entry: TablesInsert<"songstats_quota_ledger">, -): Promise { - const { error } = await supabase.from("songstats_quota_ledger").insert([entry]); - - if (error) { - throw new Error(`Failed to record songstats quota spend: ${error.message}`); - } -} diff --git a/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts b/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts deleted file mode 100644 index 0610a4b27..000000000 --- a/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts +++ /dev/null @@ -1,23 +0,0 @@ -import supabase from "../serverClient"; - -/** - * Sum Songstats hits spent since a window start (the rolling 30-day quota - * check). Errors are treated as the full quota being spent — failing safe: - * the worker must never overspend because the ledger was unreadable. - * - * @param since - Inclusive ISO lower bound of the window - * @returns Total hits spent in the window - */ -export async function selectSongstatsQuotaSpent(since: string): Promise { - const { data, error } = await supabase - .from("songstats_quota_ledger") - .select("hits") - .gte("spent_at", since); - - if (error) { - console.error("Error reading songstats quota ledger:", error); - return Number.MAX_SAFE_INTEGER; - } - - return (data || []).reduce((sum, row) => sum + row.hits, 0); -} From f24d4c7ecaf4469c234378916dafffddc1425416 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 18 Jun 2026 15:06:39 -0500 Subject: [PATCH 04/27] feat: POST /api/catalogs (create + materialize from valuation snapshot) (#677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: POST /api/catalogs create + materialize from valuation snapshot Creates a catalog owned by the authenticated account (account derived from credentials via validateAuthContext, never the body). With from.snapshot_id, materializes the catalog from a completed valuation snapshot: creates the catalogs row, links account_catalogs, adds the snapshot's measured ISRCs as catalog_songs, and records the catalog on the snapshot. Re-claiming the same snapshot is idempotent. TDD: validateCreateCatalogBody (6 tests) + createCatalogHandler (8 tests), red->green. New supabase wrappers: insertCatalog, selectCatalogById, insertAccountCatalog, updateSnapshotCatalog. Implements recoupable/chat#1801 Phase 2. Matches docs contract recoupable/docs#243. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: re-anchor POST /api/catalogs to merged contract + review fixes - Flatten request to the merged docs#243 contract: from:{snapshot_id} -> a root snapshot field (validator + handler + tests). Error copy follows. - DRY/SRP: drop the inline success() helper; use the shared successResponse(). - KISS rename: materializeSnapshotCatalog.ts -> createSnapshotCatalog.ts. - DRY: delete the redundant updateSnapshotCatalog helper; reuse the existing updatePlaycountSnapshot(id, fields). Validator change done red->green. lib/catalog: 24 tests pass; tsc + eslint clean. Addresses review on PR #677. * fix: materialize catalog songs from song_measurements, not snapshot.isrcs Testing the full materialize path surfaced a real bug: a valuation snapshot is album_ids-scoped, so its own isrcs column is null — createSnapshotCatalog read snapshot.isrcs and would link an EMPTY catalog. The measured ISRCs live in song_measurements (snapshot lineage), so source them there. New selectSnapshotIsrcs(snapshotId) helper (distinct song_measurements.song for the snapshot). createSnapshotCatalog now uses it. TDD: new createSnapshotCatalog.test.ts (3 tests) red->green; lib/catalog 27 pass. Addresses PR #677 verification. * refactor: reuse selectSongMeasurements (snapshot filter) instead of a new helper KISS/DRY per review: drop selectSnapshotIsrcs; add an optional snapshot filter to the existing selectSongMeasurements, and derive distinct ISRCs in createSnapshotCatalog. lib/catalog + song_measurements: 36 tests pass. Addresses review on PR #677. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/catalogs/route.ts | 26 +++ .../__tests__/createCatalogHandler.test.ts | 160 ++++++++++++++++++ .../__tests__/createSnapshotCatalog.test.ts | 92 ++++++++++ .../validateCreateCatalogBody.test.ts | 50 ++++++ lib/catalog/createCatalogHandler.ts | 74 ++++++++ lib/catalog/createSnapshotCatalog.ts | 45 +++++ lib/catalog/validateCreateCatalogBody.ts | 47 +++++ .../account_catalogs/insertAccountCatalog.ts | 21 +++ lib/supabase/catalogs/insertCatalog.ts | 19 +++ lib/supabase/catalogs/selectCatalogById.ts | 19 +++ .../selectSongMeasurements.ts | 14 +- 11 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 app/api/catalogs/route.ts create mode 100644 lib/catalog/__tests__/createCatalogHandler.test.ts create mode 100644 lib/catalog/__tests__/createSnapshotCatalog.test.ts create mode 100644 lib/catalog/__tests__/validateCreateCatalogBody.test.ts create mode 100644 lib/catalog/createCatalogHandler.ts create mode 100644 lib/catalog/createSnapshotCatalog.ts create mode 100644 lib/catalog/validateCreateCatalogBody.ts create mode 100644 lib/supabase/account_catalogs/insertAccountCatalog.ts create mode 100644 lib/supabase/catalogs/insertCatalog.ts create mode 100644 lib/supabase/catalogs/selectCatalogById.ts diff --git a/app/api/catalogs/route.ts b/app/api/catalogs/route.ts new file mode 100644 index 000000000..5ab6c6692 --- /dev/null +++ b/app/api/catalogs/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createCatalogHandler } from "@/lib/catalog/createCatalogHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST handler for creating a catalog (optionally materialized from a + * valuation snapshot). + * + * @param request - The request object containing the catalog body. + * @returns A NextResponse with the created catalog. + */ +export async function POST(request: NextRequest) { + return createCatalogHandler(request); +} diff --git a/lib/catalog/__tests__/createCatalogHandler.test.ts b/lib/catalog/__tests__/createCatalogHandler.test.ts new file mode 100644 index 000000000..5e2b1ebf0 --- /dev/null +++ b/lib/catalog/__tests__/createCatalogHandler.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { createCatalogHandler } from "../createCatalogHandler"; +import { validateCreateCatalogBody } from "../validateCreateCatalogBody"; +import { createSnapshotCatalog } from "../createSnapshotCatalog"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots"; +import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("../validateCreateCatalogBody", () => ({ validateCreateCatalogBody: vi.fn() })); +vi.mock("../createSnapshotCatalog", () => ({ createSnapshotCatalog: vi.fn() })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); +vi.mock("@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots", () => ({ + selectPlaycountSnapshots: vi.fn(), +})); +vi.mock("@/lib/supabase/catalogs/selectCatalogById", () => ({ selectCatalogById: vi.fn() })); +vi.mock("@/lib/supabase/catalogs/insertCatalog", () => ({ insertCatalog: vi.fn() })); +vi.mock("@/lib/supabase/account_catalogs/insertAccountCatalog", () => ({ + insertAccountCatalog: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const otherAccountId = "550e8400-e29b-41d4-a716-446655440999"; +const snapshotId = "11111111-2222-3333-4444-555555555555"; +const catalogId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + +const catalog = { + id: catalogId, + name: "Bad Bunny — Catalog", + created_at: "2026-06-18T00:00:00Z", + updated_at: "2026-06-18T00:00:00Z", +}; + +const makeRequest = () => new NextRequest("http://localhost/api/catalogs", { method: "POST" }); + +const okAuth = () => + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "t" }); + +describe("createCatalogHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("short-circuits with the validator error and never authenticates", async () => { + const err = NextResponse.json({ status: "error" }, { status: 400 }); + vi.mocked(validateCreateCatalogBody).mockReturnValue(err); + + const res = await createCatalogHandler(makeRequest()); + + expect(res).toBe(err); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("short-circuits with the auth error when unauthenticated", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "X" }); + const authErr = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + + const res = await createCatalogHandler(makeRequest()); + + expect(res).toBe(authErr); + expect(insertCatalog).not.toHaveBeenCalled(); + }); + + it("creates an empty catalog from a name-only body and links the account", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "Bad Bunny — Catalog" }); + okAuth(); + vi.mocked(insertCatalog).mockResolvedValue(catalog); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(insertCatalog).toHaveBeenCalledWith("Bad Bunny — Catalog"); + expect(insertAccountCatalog).toHaveBeenCalledWith({ account: accountId, catalog: catalogId }); + expect(body).toEqual({ status: "success", catalog, songs_added: 0 }); + expect(selectPlaycountSnapshots).not.toHaveBeenCalled(); + }); + + it("materializes a catalog from an owned, not-yet-claimed snapshot", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ + name: "Bad Bunny — Catalog", + snapshot: snapshotId, + }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([ + { id: snapshotId, account: accountId, catalog: null, isrcs: ["A", "B"] } as never, + ]); + vi.mocked(createSnapshotCatalog).mockResolvedValue({ catalog, songsAdded: 2 }); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(createSnapshotCatalog).toHaveBeenCalledWith({ + accountId, + snapshot: expect.objectContaining({ id: snapshotId }), + name: "Bad Bunny — Catalog", + }); + expect(body).toEqual({ status: "success", catalog, songs_added: 2 }); + }); + + it("returns 404 when the snapshot does not exist", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([]); + + const res = await createCatalogHandler(makeRequest()); + + expect(res.status).toBe(404); + expect(createSnapshotCatalog).not.toHaveBeenCalled(); + }); + + it("returns 403 when the snapshot belongs to a different account", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([ + { id: snapshotId, account: otherAccountId, catalog: null, isrcs: ["A"] } as never, + ]); + + const res = await createCatalogHandler(makeRequest()); + + expect(res.status).toBe(403); + expect(createSnapshotCatalog).not.toHaveBeenCalled(); + }); + + it("is idempotent: returns the existing catalog when the snapshot is already claimed", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([ + { id: snapshotId, account: accountId, catalog: catalogId, isrcs: ["A", "B"] } as never, + ]); + vi.mocked(selectCatalogById).mockResolvedValue(catalog); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(selectCatalogById).toHaveBeenCalledWith(catalogId); + expect(createSnapshotCatalog).not.toHaveBeenCalled(); + expect(body).toEqual({ status: "success", catalog, songs_added: 0 }); + }); + + it("returns a generic 500 without leaking the underlying error", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "X" }); + okAuth(); + vi.mocked(insertCatalog).mockRejectedValue(new Error("db down at 10.0.0.1:5432")); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.status).toBe("error"); + expect(JSON.stringify(body)).not.toContain("10.0.0.1"); + }); +}); diff --git a/lib/catalog/__tests__/createSnapshotCatalog.test.ts b/lib/catalog/__tests__/createSnapshotCatalog.test.ts new file mode 100644 index 000000000..9ee7a3ede --- /dev/null +++ b/lib/catalog/__tests__/createSnapshotCatalog.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createSnapshotCatalog } from "../createSnapshotCatalog"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; +import { insertCatalogSongs } from "@/lib/supabase/catalog_songs/insertCatalogSongs"; +import { updatePlaycountSnapshot } from "@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; + +vi.mock("@/lib/supabase/catalogs/insertCatalog", () => ({ insertCatalog: vi.fn() })); +vi.mock("@/lib/supabase/account_catalogs/insertAccountCatalog", () => ({ + insertAccountCatalog: vi.fn(), +})); +vi.mock("@/lib/supabase/catalog_songs/insertCatalogSongs", () => ({ insertCatalogSongs: vi.fn() })); +vi.mock("@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot", () => ({ + updatePlaycountSnapshot: vi.fn(), +})); +vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ + selectSongMeasurements: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const snapshotId = "11111111-2222-3333-4444-555555555555"; +const catalogId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const catalog = { id: catalogId, name: "Bad Bunny Catalog", created_at: "t", updated_at: "t" }; +// A real valuation snapshot is scoped by album_ids, so its own `isrcs` column is null — +// the measured ISRCs live in song_measurements (sourced via selectSongMeasurements). +const snapshot = { id: snapshotId, account: accountId, catalog: null, isrcs: null } as never; +const measurement = (song: string) => ({ song }) as never; + +describe("createSnapshotCatalog", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(insertCatalog).mockResolvedValue(catalog); + }); + + it("sources measured ISRCs from song_measurements (by snapshot) and adds them as catalog songs", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([ + measurement("ISRC_A"), + measurement("ISRC_B"), + measurement("ISRC_C"), + ]); + + const result = await createSnapshotCatalog({ accountId, snapshot, name: "Bad Bunny Catalog" }); + + expect(insertCatalog).toHaveBeenCalledWith("Bad Bunny Catalog"); + expect(insertAccountCatalog).toHaveBeenCalledWith({ account: accountId, catalog: catalogId }); + // ISRCs come from measurements by snapshot, NOT snapshot.isrcs (null here) + expect(selectSongMeasurements).toHaveBeenCalledWith({ snapshot: snapshotId }); + expect(insertCatalogSongs).toHaveBeenCalledWith([ + { catalog: catalogId, song: "ISRC_A" }, + { catalog: catalogId, song: "ISRC_B" }, + { catalog: catalogId, song: "ISRC_C" }, + ]); + expect(updatePlaycountSnapshot).toHaveBeenCalledWith(snapshotId, { catalog: catalogId }); + expect(result).toEqual({ catalog, songsAdded: 3 }); + }); + + it("dedupes ISRCs across multiple measurement rows per track", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([ + measurement("ISRC_A"), + measurement("ISRC_A"), + measurement("ISRC_B"), + ]); + + const result = await createSnapshotCatalog({ accountId, snapshot }); + + expect(insertCatalogSongs).toHaveBeenCalledWith([ + { catalog: catalogId, song: "ISRC_A" }, + { catalog: catalogId, song: "ISRC_B" }, + ]); + expect(result).toEqual({ catalog, songsAdded: 2 }); + }); + + it("adds no songs when the snapshot has no measurements", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([]); + + const result = await createSnapshotCatalog({ accountId, snapshot }); + + expect(insertCatalogSongs).not.toHaveBeenCalled(); + expect(updatePlaycountSnapshot).toHaveBeenCalledWith(snapshotId, { catalog: catalogId }); + expect(result).toEqual({ catalog, songsAdded: 0 }); + }); + + it("falls back to a default name when none is supplied", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([]); + + await createSnapshotCatalog({ accountId, snapshot }); + + expect(insertCatalog).toHaveBeenCalledWith("Valuation Catalog"); + }); +}); diff --git a/lib/catalog/__tests__/validateCreateCatalogBody.test.ts b/lib/catalog/__tests__/validateCreateCatalogBody.test.ts new file mode 100644 index 000000000..670895f6a --- /dev/null +++ b/lib/catalog/__tests__/validateCreateCatalogBody.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; + +import { validateCreateCatalogBody } from "../validateCreateCatalogBody"; + +const snapshotId = "550e8400-e29b-41d4-a716-446655440000"; + +describe("validateCreateCatalogBody", () => { + it("accepts a name-only body", () => { + const result = validateCreateCatalogBody({ name: "My Catalog" }); + expect(result).toEqual({ name: "My Catalog" }); + }); + + it("accepts a snapshot-only body", () => { + const result = validateCreateCatalogBody({ snapshot: snapshotId }); + expect(result).toEqual({ snapshot: snapshotId }); + }); + + it("accepts name and snapshot together", () => { + const result = validateCreateCatalogBody({ + name: "Bad Bunny — Catalog", + snapshot: snapshotId, + }); + expect(result).toEqual({ + name: "Bad Bunny — Catalog", + snapshot: snapshotId, + }); + }); + + it("rejects an empty body (neither name nor snapshot) with 400", async () => { + const result = validateCreateCatalogBody({}); + expect(result).toBeInstanceOf(NextResponse); + const res = result as NextResponse; + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.status).toBe("error"); + }); + + it("rejects a non-uuid snapshot with 400", async () => { + const result = validateCreateCatalogBody({ snapshot: "not-a-uuid" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("rejects a null/non-object body with 400", async () => { + const result = validateCreateCatalogBody(null); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); +}); diff --git a/lib/catalog/createCatalogHandler.ts b/lib/catalog/createCatalogHandler.ts new file mode 100644 index 000000000..583746971 --- /dev/null +++ b/lib/catalog/createCatalogHandler.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateCreateCatalogBody } from "./validateCreateCatalogBody"; +import { createSnapshotCatalog } from "./createSnapshotCatalog"; +import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots"; +import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; + +const DEFAULT_CATALOG_NAME = "Valuation Catalog"; + +/** + * POST /api/catalogs + * + * Creates a catalog owned by the authenticated account. The owning account is + * resolved from credentials (Privy bearer or x-api-key), never from the body. + * + * With `snapshot`, materializes the catalog from a completed valuation snapshot: + * the snapshot must be owned by the caller, and re-claiming the same snapshot is + * idempotent (returns the catalog already created for that run). + * + * @param request - The request object + * @returns A NextResponse with `{ status, catalog, songs_added }` + */ +export async function createCatalogHandler(request: NextRequest): Promise { + try { + const body = await request.json().catch(() => null); + + const validated = validateCreateCatalogBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + if (!validated.snapshot) { + const catalog = await insertCatalog(validated.name ?? DEFAULT_CATALOG_NAME); + await insertAccountCatalog({ account: accountId, catalog: catalog.id }); + return successResponse({ catalog, songs_added: 0 }); + } + + const [snapshot] = await selectPlaycountSnapshots({ id: validated.snapshot }); + if (!snapshot) { + return errorResponse("Snapshot not found", 404); + } + if (snapshot.account !== accountId) { + return errorResponse("Snapshot belongs to a different account", 403); + } + + // Idempotent re-claim: the run already produced a catalog. + if (snapshot.catalog) { + const existing = await selectCatalogById(snapshot.catalog); + if (existing) { + return successResponse({ catalog: existing, songs_added: 0 }); + } + } + + const { catalog, songsAdded } = await createSnapshotCatalog({ + accountId, + snapshot, + name: validated.name, + }); + return successResponse({ catalog, songs_added: songsAdded }); + } catch (error) { + console.error("Error creating catalog:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/catalog/createSnapshotCatalog.ts b/lib/catalog/createSnapshotCatalog.ts new file mode 100644 index 000000000..ab1ba96c2 --- /dev/null +++ b/lib/catalog/createSnapshotCatalog.ts @@ -0,0 +1,45 @@ +import { Tables } from "@/types/database.types"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; +import { insertCatalogSongs } from "@/lib/supabase/catalog_songs/insertCatalogSongs"; +import { updatePlaycountSnapshot } from "@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; + +const DEFAULT_CATALOG_NAME = "Valuation Catalog"; + +/** + * Creates an account-linked catalog from a valuation snapshot: creates the + * `catalogs` row, links it to the account via `account_catalogs`, adds the + * snapshot's **measured** ISRCs (from `song_measurements`, not the snapshot's + * own `isrcs` column — that's null for album-scoped valuation runs) as + * `catalog_songs`, and records the new catalog on the snapshot (the idempotency + * key for re-claims). + * + * Callers must first confirm the snapshot is owned by `accountId` and not yet + * claimed (`snapshot.catalog` is null). + * + * @param params.accountId - Owning account (already authorized) + * @param params.snapshot - The owned, unclaimed snapshot row + * @param params.name - Optional catalog name; falls back to a default + * @returns The created catalog and the number of songs added + */ +export async function createSnapshotCatalog(params: { + accountId: string; + snapshot: Tables<"playcount_snapshots">; + name?: string; +}): Promise<{ catalog: Tables<"catalogs">; songsAdded: number }> { + const { accountId, snapshot, name } = params; + + const catalog = await insertCatalog(name ?? DEFAULT_CATALOG_NAME); + await insertAccountCatalog({ account: accountId, catalog: catalog.id }); + + const measurements = await selectSongMeasurements({ snapshot: snapshot.id }); + const isrcs = [...new Set(measurements.map(m => m.song))]; + if (isrcs.length > 0) { + await insertCatalogSongs(isrcs.map(isrc => ({ catalog: catalog.id, song: isrc }))); + } + + await updatePlaycountSnapshot(snapshot.id, { catalog: catalog.id }); + + return { catalog, songsAdded: isrcs.length }; +} diff --git a/lib/catalog/validateCreateCatalogBody.ts b/lib/catalog/validateCreateCatalogBody.ts new file mode 100644 index 000000000..b41c058c1 --- /dev/null +++ b/lib/catalog/validateCreateCatalogBody.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createCatalogBodySchema = z + .object({ + name: z.string().min(1, "name must not be empty").optional(), + snapshot: z.string().uuid("snapshot must be a valid UUID").optional(), + }) + .refine(data => data.name !== undefined || data.snapshot !== undefined, { + message: "Provide at least one of name or snapshot", + }); + +export type CreateCatalogBody = z.infer; + +/** + * Validates a create-catalog request body. + * + * Accepts `{ name?, snapshot? }`; at least one is required. `snapshot` is a + * completed playcount snapshot id (valuation run) to materialize from. The + * owning account is never taken from the body - it is resolved from the + * request credentials by the handler. + * + * @param body - The parsed request body to validate. + * @returns A NextResponse with a 400 error if validation fails, or the + * validated body if it passes. + */ +export function validateCreateCatalogBody(body: unknown): NextResponse | CreateCatalogBody { + const result = createCatalogBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/supabase/account_catalogs/insertAccountCatalog.ts b/lib/supabase/account_catalogs/insertAccountCatalog.ts new file mode 100644 index 000000000..1facd5b63 --- /dev/null +++ b/lib/supabase/account_catalogs/insertAccountCatalog.ts @@ -0,0 +1,21 @@ +import supabase from "../serverClient"; + +/** + * Links a catalog to an account by inserting an `account_catalogs` row. + * + * @param params.account - Account id (owner) + * @param params.catalog - Catalog id to link + * @throws Error if the insert fails + */ +export async function insertAccountCatalog(params: { + account: string; + catalog: string; +}): Promise { + const { error } = await supabase + .from("account_catalogs") + .insert({ account: params.account, catalog: params.catalog }); + + if (error) { + throw new Error(`Failed to link account_catalogs: ${error.message}`); + } +} diff --git a/lib/supabase/catalogs/insertCatalog.ts b/lib/supabase/catalogs/insertCatalog.ts new file mode 100644 index 000000000..ecd0c667b --- /dev/null +++ b/lib/supabase/catalogs/insertCatalog.ts @@ -0,0 +1,19 @@ +import supabase from "../serverClient"; +import { Tables } from "@/types/database.types"; + +/** + * Inserts a new `catalogs` row and returns it. + * + * @param name - Display name for the catalog + * @returns The inserted catalog row + * @throws Error if the insert fails + */ +export async function insertCatalog(name: string): Promise> { + const { data, error } = await supabase.from("catalogs").insert({ name }).select().single(); + + if (error || !data) { + throw new Error(`Failed to insert catalog: ${error?.message ?? "no row returned"}`); + } + + return data; +} diff --git a/lib/supabase/catalogs/selectCatalogById.ts b/lib/supabase/catalogs/selectCatalogById.ts new file mode 100644 index 000000000..39f961e3d --- /dev/null +++ b/lib/supabase/catalogs/selectCatalogById.ts @@ -0,0 +1,19 @@ +import supabase from "../serverClient"; +import { Tables } from "@/types/database.types"; + +/** + * Selects a single `catalogs` row by id, or null if none exists. + * + * @param id - Catalog id + * @returns The catalog row, or null if not found + * @throws Error if the query fails + */ +export async function selectCatalogById(id: string): Promise | null> { + const { data, error } = await supabase.from("catalogs").select("*").eq("id", id).maybeSingle(); + + if (error) { + throw new Error(`Failed to fetch catalog: ${error.message}`); + } + + return data; +} diff --git a/lib/supabase/song_measurements/selectSongMeasurements.ts b/lib/supabase/song_measurements/selectSongMeasurements.ts index 24d50bd44..1e118d14b 100644 --- a/lib/supabase/song_measurements/selectSongMeasurements.ts +++ b/lib/supabase/song_measurements/selectSongMeasurements.ts @@ -2,13 +2,14 @@ import supabase from "../serverClient"; import { Tables } from "@/types/database.types"; /** - * Select measurements newest-first. Filter by one song or a batch of songs - * (one is required), and optionally by platform/metric. Uses the - * (song, platform, metric, captured_at DESC) series index; pass `limit: 1` - * for the latest capture, omit for the full series. + * Select measurements newest-first. Filter by one song, a batch of songs, or a + * snapshot (one of the three is required), and optionally by platform/metric. + * Uses the (song, platform, metric, captured_at DESC) series index; pass + * `limit: 1` for the latest capture, omit for the full series. * * @param params.song - Single song ISRC filter * @param params.songs - Batch of song ISRCs (e.g. all tracks on an album) + * @param params.snapshot - Snapshot id filter (all measurements captured for a run) * @param params.platform - Optional platform filter (e.g. "spotify") * @param params.metric - Optional metric filter (e.g. "platform_displayed_play_count") * @param params.limit - Optional cap on returned rows @@ -17,17 +18,19 @@ import { Tables } from "@/types/database.types"; export async function selectSongMeasurements({ song, songs, + snapshot, platform, metric, limit, }: { song?: string; songs?: string[]; + snapshot?: string; platform?: string; metric?: string; limit?: number; }): Promise[]> { - if (!song && (!songs || songs.length === 0)) return []; + if (!song && (!songs || songs.length === 0) && !snapshot) return []; let query = supabase .from("song_measurements") @@ -36,6 +39,7 @@ export async function selectSongMeasurements({ if (song) query = query.eq("song", song); if (songs && songs.length > 0) query = query.in("song", songs); + if (snapshot) query = query.eq("snapshot", snapshot); if (platform) query = query.eq("platform", platform); if (metric) query = query.eq("metric", metric); if (limit) query = query.limit(limit); From a520a1ddac9b7dd4f3bf9d040c2270ecdb7c1dbe Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 18 Jun 2026 17:00:50 -0500 Subject: [PATCH 05/27] fix: LEFT-join artists in catalog-songs read (materialized tracks were hidden) (#681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: LEFT-join artists in catalog-songs read so materialized tracks surface selectCatalogSongsWithArtists used song_artists!inner -> accounts!inner, so valuation-captured tracks (which have songs + song_measurements but no song_artists yet) were filtered out — a materialized catalog read back as 0 songs (verified live on api#677). Drop the two !inner so artist-less songs return with artists: []; songs!inner stays (catalog_songs.song FK guarantees it). Closes the read-path half of the song_artists follow-up in recoupable/chat#1801. Longer-term (option a): the capture pipeline should also write song_artists. * Update lib/supabase/catalog_songs/selectCatalogSongsWithArtists.ts --- lib/supabase/catalog_songs/selectCatalogSongsWithArtists.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/supabase/catalog_songs/selectCatalogSongsWithArtists.ts b/lib/supabase/catalog_songs/selectCatalogSongsWithArtists.ts index 8639eb405..47cc14206 100644 --- a/lib/supabase/catalog_songs/selectCatalogSongsWithArtists.ts +++ b/lib/supabase/catalog_songs/selectCatalogSongsWithArtists.ts @@ -55,9 +55,9 @@ export async function selectCatalogSongsWithArtists( album, notes, updated_at, - song_artists!inner ( + song_artists ( artist, - accounts!inner ( + accounts ( id, name, timestamp From 709ea0cb339e425cdf4b27cbbd34bfaccf374319 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 18 Jun 2026 17:58:44 -0500 Subject: [PATCH 06/27] feat: add X (Twitter) + LinkedIn to the Composio connector whitelist (chat#1793) (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add X (Twitter) + LinkedIn to the Composio connector whitelist (chat#1793) Expand the existing whitelist pattern to two new platforms — no architecture changes: - SUPPORTED_TOOLKITS (getConnectors.ts) + ENABLED_TOOLKITS (getComposioTools.ts) - CONNECTOR_DISPLAY_NAMES: twitter → "X (Twitter)", linkedin → "LinkedIn" - buildAuthConfigs() reads COMPOSIO_TWITTER_AUTH_CONFIG_ID + COMPOSIO_LINKEDIN_AUTH_CONFIG_ID - document both env vars in .env.example TDD: new buildAuthConfigs unit + expanded getConnectors / handler / ENABLED_TOOLKITS assertions, RED before GREEN. Full lib/composio suite green (157 tests). Implements the contract from docs#244. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: fix lint/format — relocate ENABLED_TOOLKITS test block, reformat toolkit array - Move the ENABLED_TOOLKITS describe block below the imports (import/first) - Prettier-format the expanded toolkits array in getConnectors.test.ts Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .env.example | 2 + .../__tests__/buildAuthConfigs.test.ts | 48 +++++++++++++++++++ .../__tests__/getConnectors.test.ts | 22 ++++++++- .../__tests__/getConnectorsHandler.test.ts | 2 + lib/composio/connectors/buildAuthConfigs.ts | 6 +++ lib/composio/connectors/getConnectors.ts | 2 + .../connectors/getConnectorsHandler.ts | 2 + .../__tests__/getComposioTools.test.ts | 9 +++- lib/composio/toolRouter/getComposioTools.ts | 2 + 9 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 lib/composio/connectors/__tests__/buildAuthConfigs.test.ts diff --git a/.env.example b/.env.example index 9d6de84d9..80bca6864 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,8 @@ COMPOSIO_INSTAGRAM_AUTH_CONFIG_ID= COMPOSIO_GOOGLE_SHEETS_AUTH_CONFIG_ID= COMPOSIO_GOOGLE_DOCS_AUTH_CONFIG_ID= COMPOSIO_GOOGLE_DRIVE_AUTH_CONFIG_ID= +COMPOSIO_TWITTER_AUTH_CONFIG_ID= +COMPOSIO_LINKEDIN_AUTH_CONFIG_ID= # Slack (coding agent) SLACK_BOT_TOKEN= diff --git a/lib/composio/connectors/__tests__/buildAuthConfigs.test.ts b/lib/composio/connectors/__tests__/buildAuthConfigs.test.ts new file mode 100644 index 000000000..451dc92ed --- /dev/null +++ b/lib/composio/connectors/__tests__/buildAuthConfigs.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { buildAuthConfigs } from "../buildAuthConfigs"; + +const AUTH_CONFIG_ENV_KEYS = [ + "COMPOSIO_TIKTOK_AUTH_CONFIG_ID", + "COMPOSIO_INSTAGRAM_AUTH_CONFIG_ID", + "COMPOSIO_GOOGLE_SHEETS_AUTH_CONFIG_ID", + "COMPOSIO_GOOGLE_DOCS_AUTH_CONFIG_ID", + "COMPOSIO_GOOGLE_DRIVE_AUTH_CONFIG_ID", + "COMPOSIO_YOUTUBE_AUTH_CONFIG_ID", + "COMPOSIO_TWITTER_AUTH_CONFIG_ID", + "COMPOSIO_LINKEDIN_AUTH_CONFIG_ID", +]; + +describe("buildAuthConfigs", () => { + let saved: Record; + + beforeEach(() => { + // Snapshot then clear every auth-config env var so the test is isolated + // from whatever .env.local provides. + saved = {}; + for (const key of AUTH_CONFIG_ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of AUTH_CONFIG_ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }); + + it("returns undefined when no auth-config env vars are set", () => { + expect(buildAuthConfigs()).toBeUndefined(); + }); + + it("reads the Twitter and LinkedIn auth configs from env", () => { + process.env.COMPOSIO_TWITTER_AUTH_CONFIG_ID = "ac_twitter_123"; + process.env.COMPOSIO_LINKEDIN_AUTH_CONFIG_ID = "ac_linkedin_456"; + + expect(buildAuthConfigs()).toEqual({ + twitter: "ac_twitter_123", + linkedin: "ac_linkedin_456", + }); + }); +}); diff --git a/lib/composio/connectors/__tests__/getConnectors.test.ts b/lib/composio/connectors/__tests__/getConnectors.test.ts index 762c07e07..b637f1026 100644 --- a/lib/composio/connectors/__tests__/getConnectors.test.ts +++ b/lib/composio/connectors/__tests__/getConnectors.test.ts @@ -37,7 +37,16 @@ describe("getConnectors", () => { expect(getComposioClient).toHaveBeenCalled(); expect(mockComposio.create).toHaveBeenCalledWith("account-123", { - toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok", "instagram", "youtube"], + toolkits: [ + "googlesheets", + "googledrive", + "googledocs", + "tiktok", + "instagram", + "youtube", + "twitter", + "linkedin", + ], }); expect(result).toEqual([ { @@ -92,7 +101,16 @@ describe("getConnectors", () => { await getConnectors("account-123"); expect(mockComposio.create).toHaveBeenCalledWith("account-123", { - toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok", "instagram", "youtube"], + toolkits: [ + "googlesheets", + "googledrive", + "googledocs", + "tiktok", + "instagram", + "youtube", + "twitter", + "linkedin", + ], authConfigs: { tiktok: "ac_tiktok_123", instagram: "ac_instagram_456", diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts index d6c3fe31f..bc68d0af6 100644 --- a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -72,6 +72,8 @@ describe("getConnectorsHandler", () => { googlesheets: "Google Sheets", googledrive: "Google Drive", googledocs: "Google Docs", + twitter: "X (Twitter)", + linkedin: "LinkedIn", }, }); }); diff --git a/lib/composio/connectors/buildAuthConfigs.ts b/lib/composio/connectors/buildAuthConfigs.ts index 2c31d571a..4edb893c0 100644 --- a/lib/composio/connectors/buildAuthConfigs.ts +++ b/lib/composio/connectors/buildAuthConfigs.ts @@ -23,5 +23,11 @@ export function buildAuthConfigs(): Record | undefined { if (process.env.COMPOSIO_YOUTUBE_AUTH_CONFIG_ID) { configs.youtube = process.env.COMPOSIO_YOUTUBE_AUTH_CONFIG_ID; } + if (process.env.COMPOSIO_TWITTER_AUTH_CONFIG_ID) { + configs.twitter = process.env.COMPOSIO_TWITTER_AUTH_CONFIG_ID; + } + if (process.env.COMPOSIO_LINKEDIN_AUTH_CONFIG_ID) { + configs.linkedin = process.env.COMPOSIO_LINKEDIN_AUTH_CONFIG_ID; + } return Object.keys(configs).length > 0 ? configs : undefined; } diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index dcb5fb38e..d692bf40c 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -23,6 +23,8 @@ const SUPPORTED_TOOLKITS = [ "tiktok", "instagram", "youtube", + "twitter", + "linkedin", ]; /** diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts index d92f1293d..86633e32b 100644 --- a/lib/composio/connectors/getConnectorsHandler.ts +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -12,6 +12,8 @@ const CONNECTOR_DISPLAY_NAMES: Record = { googlesheets: "Google Sheets", googledrive: "Google Drive", googledocs: "Google Docs", + twitter: "X (Twitter)", + linkedin: "LinkedIn", }; /** diff --git a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts index f49aff1d1..ae8d5d718 100644 --- a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts +++ b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { getComposioTools } from "../getComposioTools"; +import { getComposioTools, ENABLED_TOOLKITS } from "../getComposioTools"; import { getComposioClient } from "../../client"; import { getCallbackUrl } from "../../getCallbackUrl"; @@ -235,3 +235,10 @@ describe("getComposioTools", () => { expect(result).toHaveProperty("COMPOSIO_MULTI_EXECUTE_TOOL"); }); }); + +describe("ENABLED_TOOLKITS", () => { + it("includes twitter and linkedin", () => { + expect(ENABLED_TOOLKITS).toContain("twitter"); + expect(ENABLED_TOOLKITS).toContain("linkedin"); + }); +}); diff --git a/lib/composio/toolRouter/getComposioTools.ts b/lib/composio/toolRouter/getComposioTools.ts index 61f0f887f..7f74b4c2f 100644 --- a/lib/composio/toolRouter/getComposioTools.ts +++ b/lib/composio/toolRouter/getComposioTools.ts @@ -16,6 +16,8 @@ export const ENABLED_TOOLKITS = [ "tiktok", "instagram", "youtube", + "twitter", + "linkedin", ]; const SHARED_ACCOUNT_ID = "recoup-shared-767f498e-e1e9-43c6-a152-a96ae3bd8d07"; From 66cc2fe2518ae526d15f12a70528de93410af815 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 18 Jun 2026 18:30:11 -0500 Subject: [PATCH 07/27] chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) (#680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: allow artists to connect X (Twitter); keep LinkedIn label-only (chat#1793) Add `twitter` to ALLOWED_ARTIST_CONNECTORS — artist-facing social, same class as tiktok/instagram/youtube. `linkedin` is intentionally left out (label/owner-only). TDD: isAllowedArtistConnector.test.ts asserts twitter allowed + linkedin excluded, RED before GREEN. Full lib/composio suite green (157 tests). Co-Authored-By: Claude Opus 4.8 (1M context) * feat: allow artists to connect LinkedIn too (chat#1793) Reversal of the earlier "LinkedIn label/owner-only" call: per owner decision 2026-06-18, LinkedIn is now an artist-facing connector like the others. Add `linkedin` to ALLOWED_ARTIST_CONNECTORS. TDD: flipped the linkedin assertions (now allowed/included), RED before GREEN. Full lib/composio suite green (159 tests). Co-Authored-By: Claude Opus 4.8 (1M context) * chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) The api copy of the artist connector allow-list had no runtime consumer — only its definition, test, and an (also-unused) barrel re-export. The connector routes are unopinionated (allow any connector for any account); the allow-list that actually drives the artist Connectors tab lives in `chat` (`lib/composio/allowedArtistConnectors.ts`). Removing the dead code. Supersedes the earlier plan to add twitter/linkedin to this api constant (decision: owner, 2026-06-18) — the artist allow-list is chat-only. Deletes isAllowedArtistConnector.ts + its test, and the barrel re-export. lib/composio suite green (149); no new tsc errors vs test (198 baseline). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../isAllowedArtistConnector.test.ts | 43 ------------------- lib/composio/connectors/index.ts | 5 --- .../connectors/isAllowedArtistConnector.ts | 16 ------- 3 files changed, 64 deletions(-) delete mode 100644 lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts delete mode 100644 lib/composio/connectors/isAllowedArtistConnector.ts diff --git a/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts b/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts deleted file mode 100644 index 596407d20..000000000 --- a/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { isAllowedArtistConnector, ALLOWED_ARTIST_CONNECTORS } from "../isAllowedArtistConnector"; - -describe("isAllowedArtistConnector", () => { - it("should return true for 'tiktok'", () => { - expect(isAllowedArtistConnector("tiktok")).toBe(true); - }); - - it("should return true for 'instagram'", () => { - expect(isAllowedArtistConnector("instagram")).toBe(true); - }); - - it("should return true for 'youtube'", () => { - expect(isAllowedArtistConnector("youtube")).toBe(true); - }); - - it("should return false for connectors not in ALLOWED_ARTIST_CONNECTORS", () => { - expect(isAllowedArtistConnector("googlesheets")).toBe(false); - expect(isAllowedArtistConnector("googledrive")).toBe(false); - expect(isAllowedArtistConnector("random")).toBe(false); - }); - - it("should return false for empty string", () => { - expect(isAllowedArtistConnector("")).toBe(false); - }); - - it("should be case-sensitive", () => { - expect(isAllowedArtistConnector("TikTok")).toBe(false); - expect(isAllowedArtistConnector("TIKTOK")).toBe(false); - }); -}); - -describe("ALLOWED_ARTIST_CONNECTORS", () => { - it("should include tiktok, instagram, and youtube", () => { - expect(ALLOWED_ARTIST_CONNECTORS).toContain("tiktok"); - expect(ALLOWED_ARTIST_CONNECTORS).toContain("instagram"); - expect(ALLOWED_ARTIST_CONNECTORS).toContain("youtube"); - }); - - it("should be a readonly array", () => { - expect(Array.isArray(ALLOWED_ARTIST_CONNECTORS)).toBe(true); - }); -}); diff --git a/lib/composio/connectors/index.ts b/lib/composio/connectors/index.ts index 848b357ba..be0178edb 100644 --- a/lib/composio/connectors/index.ts +++ b/lib/composio/connectors/index.ts @@ -5,9 +5,4 @@ export { type AuthorizeConnectorOptions, } from "./authorizeConnector"; export { disconnectConnector, type DisconnectConnectorOptions } from "./disconnectConnector"; -export { - ALLOWED_ARTIST_CONNECTORS, - isAllowedArtistConnector, - type AllowedArtistConnector, -} from "./isAllowedArtistConnector"; export { verifyConnectorOwnership } from "./verifyConnectorOwnership"; diff --git a/lib/composio/connectors/isAllowedArtistConnector.ts b/lib/composio/connectors/isAllowedArtistConnector.ts deleted file mode 100644 index f3d83d82d..000000000 --- a/lib/composio/connectors/isAllowedArtistConnector.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * List of toolkit slugs that artists are allowed to connect. - * Only these connectors will be shown in the artist-connectors API. - */ -export const ALLOWED_ARTIST_CONNECTORS = ["tiktok", "instagram", "youtube"] as const; - -export type AllowedArtistConnector = (typeof ALLOWED_ARTIST_CONNECTORS)[number]; - -/** - * Check if a connector slug is an allowed artist connector. - * - * @param slug - */ -export function isAllowedArtistConnector(slug: string): slug is AllowedArtistConnector { - return (ALLOWED_ARTIST_CONNECTORS as readonly string[]).includes(slug); -} From 6fc10cc3659360ee582c045509004764e086d7be Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 18 Jun 2026 18:52:05 -0500 Subject: [PATCH 08/27] fix: enrich valuation-captured songs (artists + notes) so they render in the catalog (#684) * fix: enrich captured songs with artists + notes (root cause) The valuation capture path created songs rows from the Spotify track lookup but discarded track.artists and never ran the manual flow's enrichment, so captured songs had no song_artists and no notes -> the chat catalog view's isCompleteSong filter (artist + notes required, on by default) hid every valuation track (count shown, list empty). mapUnmappedAlbumTracks now carries track.artists through and runs the same enrichment as processSongsInput: linkSongsToArtists (auto-creates the artist account) + queueRedisSongs (queues note generation). TDD: new test asserts artists are linked + queued; lib/research/playcounts + lib/songs 109 tests pass. Root-cause follow-up on recoupable/chat#1801. * style: prettier-format the capture-enrichment test Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/mapUnmappedAlbumTracks.test.ts | 30 ++++++++++++++++ .../playcounts/mapUnmappedAlbumTracks.ts | 34 ++++++++++++++++--- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts b/lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts index 954a5851c..714d597f7 100644 --- a/lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts +++ b/lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts @@ -5,6 +5,8 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getTracks from "@/lib/spotify/getTracks"; import { upsertSongs } from "@/lib/supabase/songs/upsertSongs"; import { upsertSongIdentifiers } from "@/lib/supabase/song_identifiers/upsertSongIdentifiers"; +import { linkSongsToArtists } from "@/lib/songs/linkSongsToArtists"; +import { queueRedisSongs } from "@/lib/songs/queueRedisSongs"; import { SpotifyRateLimitError } from "@/lib/spotify/SpotifyRateLimitError"; vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() })); @@ -13,6 +15,8 @@ vi.mock("@/lib/supabase/songs/upsertSongs", () => ({ upsertSongs: vi.fn() })); vi.mock("@/lib/supabase/song_identifiers/upsertSongIdentifiers", () => ({ upsertSongIdentifiers: vi.fn(), })); +vi.mock("@/lib/songs/linkSongsToArtists", () => ({ linkSongsToArtists: vi.fn() })); +vi.mock("@/lib/songs/queueRedisSongs", () => ({ queueRedisSongs: vi.fn() })); const ALBUMS = [ { @@ -55,6 +59,32 @@ describe("mapUnmappedAlbumTracks", () => { expect([...mapped.entries()]).toEqual([["t_new", "ISRC_NIKES"]]); }); + it("links captured songs to their Spotify artists and queues them for note enrichment", async () => { + vi.mocked(getTracks).mockResolvedValue({ + tracks: [ + { + id: "t_new", + name: "Nikes on My Feet", + external_ids: { isrc: "ISRC_NIKES" }, + artists: [{ id: "a1", name: "Mac Miller" }], + }, + ], + error: null, + } as never); + + await mapUnmappedAlbumTracks(ALBUMS, new Set(["t_mapped", "t_noisrc"])); + + // Root-cause fix: captured songs get the same enrichment as the manual flow — + // artists linked + queued for notes — so they aren't "missing info" in the catalog. + expect(linkSongsToArtists).toHaveBeenCalledWith([ + expect.objectContaining({ + isrc: "ISRC_NIKES", + spotifyArtists: [{ id: "a1", name: "Mac Miller" }], + }), + ]); + expect(queueRedisSongs).toHaveBeenCalledWith([expect.objectContaining({ isrc: "ISRC_NIKES" })]); + }); + it("returns an empty map without Spotify calls when everything is mapped", async () => { const mapped = await mapUnmappedAlbumTracks(ALBUMS, new Set(["t_mapped", "t_new", "t_noisrc"])); diff --git a/lib/research/playcounts/mapUnmappedAlbumTracks.ts b/lib/research/playcounts/mapUnmappedAlbumTracks.ts index e987b0376..e9fdb8fcd 100644 --- a/lib/research/playcounts/mapUnmappedAlbumTracks.ts +++ b/lib/research/playcounts/mapUnmappedAlbumTracks.ts @@ -4,6 +4,10 @@ import { upsertSongs } from "@/lib/supabase/songs/upsertSongs"; import { upsertSongIdentifiers } from "@/lib/supabase/song_identifiers/upsertSongIdentifiers"; import { SpotifyAlbumPlayCounts } from "@/lib/apify/spotify/fetchSpotifyAlbumPlayCounts"; import { SpotifyRateLimitError } from "@/lib/spotify/SpotifyRateLimitError"; +import { getSpotifyArtists } from "@/lib/songs/getSpotifyArtists"; +import { linkSongsToArtists } from "@/lib/songs/linkSongsToArtists"; +import { queueRedisSongs } from "@/lib/songs/queueRedisSongs"; +import { SongWithSpotify } from "@/lib/songs/getSongsByIsrc"; /** * Self-mapping bootstrap (chat#1794): resolve ISRCs for actor tracks that have @@ -49,20 +53,35 @@ export async function mapUnmappedAlbumTracks( name: track.name, albumId: context.albumId, albumName: context.albumName, + spotifyArtists: getSpotifyArtists(track.artists ?? []), }, ]; }); if (resolved.length === 0) return new Map(); // Dedupe by ISRC: reissues put the same recording on several albums in one - // batch, and upsertSongs' DO UPDATE cannot affect the same row twice. - const songsByIsrc = new Map( + // batch, and upsertSongs' DO UPDATE cannot affect the same row twice. Carry + // the Spotify artists through for enrichment. + const songsByIsrc = new Map( resolved.map(r => [ r.isrc, - { isrc: r.isrc, name: r.name ?? null, album: r.albumName ?? null }, + { + isrc: r.isrc, + name: r.name ?? null, + album: r.albumName ?? null, + spotifyArtists: r.spotifyArtists, + }, ]), ); - await upsertSongs([...songsByIsrc.values()]); + const songs = [...songsByIsrc.values()]; + + await upsertSongs( + songs.map(song => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { spotifyArtists, ...record } = song; + return record; + }), + ); await upsertSongIdentifiers( resolved.flatMap(r => [ { song: r.isrc, platform: "spotify", identifier_type: "track_id", value: r.trackId }, @@ -72,6 +91,13 @@ export async function mapUnmappedAlbumTracks( ]), ); + // Root cause (chat#1801): give captured songs the same enrichment as the + // manual/CSV flow — link artists (auto-creating the artist account) and + // queue note generation — so valuation tracks aren't "missing info" and + // render in the catalog view rather than being filtered out. + await linkSongsToArtists(songs); + await queueRedisSongs(songs); + return new Map(resolved.map(r => [r.trackId, r.isrc])); } catch (error) { if (error instanceof SpotifyRateLimitError) throw error; From 2fa70298e159a192f63b19d59f581eddf936c282 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Fri, 19 Jun 2026 08:35:33 -0500 Subject: [PATCH 09/27] fix(tasks): let admins fetch any task by id alone (cross-account read) (#689) GET /api/tasks scopes every lookup to the caller's own account. A lookup by `id` alone therefore returns nothing when the caller's key doesn't own the task, which blocks the background worker (customer-prompt-task) from loading a customer's scheduled task config with a shared admin key. When an admin caller queries by `id` with no `account_id` param, drop the account scope so the single task is returned regardless of owner. Non-admin id lookups stay scoped to the authenticated account (no cross-account leak). ValidatedGetTasksQuery.account_id is now optional; selectScheduledActions already filters by account_id only when present. TDD: RED (admin id lookup not cross-account, non-admin not scoped) -> GREEN. Fixes part of recoupable/chat#1810. Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/validateGetTasksQuery.test.ts | 35 +++++++++++++++++++ lib/tasks/validateGetTasksQuery.ts | 16 +++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/tasks/__tests__/validateGetTasksQuery.test.ts b/lib/tasks/__tests__/validateGetTasksQuery.test.ts index b9d0dda71..21722150a 100644 --- a/lib/tasks/__tests__/validateGetTasksQuery.test.ts +++ b/lib/tasks/__tests__/validateGetTasksQuery.test.ts @@ -119,6 +119,41 @@ describe("validateGetTasksQuery", () => { }); }); + it("lets an admin fetch any task by id alone, dropping account scope", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "worker_admin_acc", + orgId: null, + authToken: "token", + }); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const result = await validateGetTasksQuery( + createMockRequest("http://localhost:3000/api/tasks?id=task_owned_by_someone_else"), + ); + + expect(checkIsAdmin).toHaveBeenCalledWith("worker_admin_acc"); + expect(validateAccountIdOverride).not.toHaveBeenCalled(); + // No account_id key -> selectScheduledActions filters by id only (cross-account read). + expect(result).toEqual({ id: "task_owned_by_someone_else" }); + }); + + it("keeps a non-admin id lookup scoped to the authenticated account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "regular_acc", + orgId: null, + authToken: "token", + }); + vi.mocked(checkIsAdmin).mockResolvedValue(false); + + const result = await validateGetTasksQuery( + createMockRequest("http://localhost:3000/api/tasks?id=task_1"), + ); + + expect(checkIsAdmin).toHaveBeenCalledWith("regular_acc"); + expect(validateAccountIdOverride).not.toHaveBeenCalled(); + expect(result).toEqual({ id: "task_1", account_id: "regular_acc" }); + }); + it("returns 403 when non-admin override is denied", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "org_owner_acc", diff --git a/lib/tasks/validateGetTasksQuery.ts b/lib/tasks/validateGetTasksQuery.ts index 3e9d56201..f581a438a 100644 --- a/lib/tasks/validateGetTasksQuery.ts +++ b/lib/tasks/validateGetTasksQuery.ts @@ -19,7 +19,9 @@ export const getTasksQuerySchema = z.object({ }); export type GetTasksQuery = z.infer; -export type ValidatedGetTasksQuery = Omit & { account_id: string }; +// account_id is optional: admin callers fetching a single task by `id` are not +// scoped to their own account, so the resolved query may omit account_id entirely. +export type ValidatedGetTasksQuery = Omit & { account_id?: string }; /** * Validates get tasks query parameters from a NextRequest. @@ -55,7 +57,7 @@ export async function validateGetTasksQuery( ); } - let targetAccountId = authResult.accountId; + let targetAccountId: string | undefined = authResult.accountId; if ( validationResult.data.account_id && @@ -76,10 +78,18 @@ export async function validateGetTasksQuery( targetAccountId = overrideResult.accountId; } + } else if (validationResult.data.id && !validationResult.data.account_id) { + // Single-task lookup by id: admins may read any task regardless of owner + // (e.g. the background worker fetching a customer's scheduled task config). + // Non-admins stay scoped to their own account. + const isAdmin = await checkIsAdmin(authResult.accountId); + if (isAdmin) { + targetAccountId = undefined; + } } return { ...validationResult.data, - account_id: targetAccountId, + ...(targetAccountId ? { account_id: targetAccountId } : {}), }; } From cdb34d09f27a50aec51cfffd423139706eaf10e2 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Sat, 20 Jun 2026 17:17:53 -0500 Subject: [PATCH 10/27] =?UTF-8?q?feat(connectors):=20POST=20/api/connector?= =?UTF-8?q?s/files=20=E2=80=94=20stage=20images=20for=20LinkedIn/X=20posts?= =?UTF-8?q?=20(#691)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(connectors): add POST /api/connectors/files (stage image for posts) Connector actions with file_uploadable fields (e.g. LINKEDIN_CREATE_LINKED_IN_POST.images[], TWITTER_CREATION_OF_A_POST) need a Composio { name, mimetype, s3key } descriptor whose s3key already lives in Composio storage. The execute path forwards parameters verbatim and never stages the file, so any s3key 404s. Add POST /api/connectors/files: given { url, toolSlug }, stage the image via composio.files.upload() and return flat { success, name, mimetype, s3key }. The caller passes that descriptor into parameters.images[] on the existing POST /api/connectors/actions. No change to the execute path (Option A). - uploadConnectorFile: calls composio.files.upload({ file: url, toolSlug, toolkitSlug }) where toolkitSlug is derived from the action slug. - validate body (zod { url, toolSlug }) + request (validateAuthContext gate; no account_id — upload is scoped by tool/toolkit, not connection). - handler returns 200 on success, 400 invalid body, 401 unauth, 502 upstream. URL-only input by decision; generic across file_uploadable toolkits (linkedin, twitter). TDD RED→GREEN; connectors suite green (129 tests). Implements recoupable/chat#1809. Docs: recoupable/docs#246. Co-Authored-By: Claude Opus 4.8 (1M context) * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(connectors): use shared safeParseJson in file-upload validator Address review (DRY): replace the raw `await request.json()` with the shared `safeParseJson` helper (lib/networking/safeParseJson), matching the other validators. Malformed JSON now yields a clean 400 via body validation instead of throwing into the handler's 502 path. TDD: added a malformed-JSON test (RED on request.json() throw) → GREEN. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/connectors/files/route.ts | 35 +++++++ .../__tests__/deriveToolkitSlug.test.ts | 16 ++++ .../__tests__/uploadConnectorFile.test.ts | 52 +++++++++++ .../uploadConnectorFileHandler.test.ts | 79 ++++++++++++++++ .../validateUploadConnectorFileBody.test.ts | 45 +++++++++ ...validateUploadConnectorFileRequest.test.ts | 91 +++++++++++++++++++ lib/composio/connectors/deriveToolkitSlug.ts | 15 +++ .../connectors/uploadConnectorFile.ts | 42 +++++++++ .../connectors/uploadConnectorFileHandler.ts | 37 ++++++++ .../validateUploadConnectorFileBody.ts | 45 +++++++++ .../validateUploadConnectorFileRequest.ts | 43 +++++++++ 11 files changed, 500 insertions(+) create mode 100644 app/api/connectors/files/route.ts create mode 100644 lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts create mode 100644 lib/composio/connectors/__tests__/uploadConnectorFile.test.ts create mode 100644 lib/composio/connectors/__tests__/uploadConnectorFileHandler.test.ts create mode 100644 lib/composio/connectors/__tests__/validateUploadConnectorFileBody.test.ts create mode 100644 lib/composio/connectors/__tests__/validateUploadConnectorFileRequest.test.ts create mode 100644 lib/composio/connectors/deriveToolkitSlug.ts create mode 100644 lib/composio/connectors/uploadConnectorFile.ts create mode 100644 lib/composio/connectors/uploadConnectorFileHandler.ts create mode 100644 lib/composio/connectors/validateUploadConnectorFileBody.ts create mode 100644 lib/composio/connectors/validateUploadConnectorFileRequest.ts diff --git a/app/api/connectors/files/route.ts b/app/api/connectors/files/route.ts new file mode 100644 index 000000000..e0e902b47 --- /dev/null +++ b/app/api/connectors/files/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { uploadConnectorFileHandler } from "@/lib/composio/connectors/uploadConnectorFileHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/connectors/files + * + * Stages an image into Composio storage so it can be attached to a connector + * action that accepts a `file_uploadable` field (e.g. + * `LINKEDIN_CREATE_LINKED_IN_POST.images[]`, `TWITTER_CREATION_OF_A_POST`). + * Requires `x-api-key` or `Authorization: Bearer`. + * + * @param request - The incoming request. JSON body: `url` (required — a + * publicly reachable image URL) and `toolSlug` (required — the + * UPPERCASE_SNAKE_CASE action slug the file will be attached to). + * @returns A 200 NextResponse with `{ success, name, mimetype, s3key }`, 400 on + * missing/invalid body, 401 when unauthenticated, or 502 when Composio fails + * to fetch or store the image. + */ +export async function POST(request: NextRequest) { + return uploadConnectorFileHandler(request); +} diff --git a/lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts b/lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts new file mode 100644 index 000000000..74c006f76 --- /dev/null +++ b/lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { deriveToolkitSlug } from "../deriveToolkitSlug"; + +describe("deriveToolkitSlug", () => { + it("derives linkedin from LINKEDIN_CREATE_LINKED_IN_POST", () => { + expect(deriveToolkitSlug("LINKEDIN_CREATE_LINKED_IN_POST")).toBe("linkedin"); + }); + + it("derives twitter from TWITTER_CREATION_OF_A_POST", () => { + expect(deriveToolkitSlug("TWITTER_CREATION_OF_A_POST")).toBe("twitter"); + }); + + it("lowercases the leading toolkit token for other slugs", () => { + expect(deriveToolkitSlug("GMAIL_SEND_EMAIL")).toBe("gmail"); + }); +}); diff --git a/lib/composio/connectors/__tests__/uploadConnectorFile.test.ts b/lib/composio/connectors/__tests__/uploadConnectorFile.test.ts new file mode 100644 index 000000000..fbb1b72a3 --- /dev/null +++ b/lib/composio/connectors/__tests__/uploadConnectorFile.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { uploadConnectorFile } from "../uploadConnectorFile"; +import { getComposioClient } from "../../client"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +describe("uploadConnectorFile", () => { + const upload = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getComposioClient).mockResolvedValue({ + files: { upload }, + } as unknown as Awaited>); + }); + + it("uploads the url scoped to the tool/toolkit and returns the flat descriptor", async () => { + upload.mockResolvedValue({ + name: "post.png", + mimetype: "image/png", + s3key: "composio/abc123", + }); + + const result = await uploadConnectorFile({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + + expect(upload).toHaveBeenCalledWith({ + file: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + toolkitSlug: "linkedin", + }); + expect(result).toEqual({ + name: "post.png", + mimetype: "image/png", + s3key: "composio/abc123", + }); + }); + + it("propagates upstream upload failures", async () => { + upload.mockRejectedValue(new Error("storage returned HTTP 404")); + await expect( + uploadConnectorFile({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + ).rejects.toThrow("storage returned HTTP 404"); + }); +}); diff --git a/lib/composio/connectors/__tests__/uploadConnectorFileHandler.test.ts b/lib/composio/connectors/__tests__/uploadConnectorFileHandler.test.ts new file mode 100644 index 000000000..9d148d455 --- /dev/null +++ b/lib/composio/connectors/__tests__/uploadConnectorFileHandler.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { uploadConnectorFileHandler } from "../uploadConnectorFileHandler"; +import { validateUploadConnectorFileRequest } from "../validateUploadConnectorFileRequest"; +import { uploadConnectorFile } from "../uploadConnectorFile"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({})), +})); + +vi.mock("../validateUploadConnectorFileRequest", () => ({ + validateUploadConnectorFileRequest: vi.fn(), +})); + +vi.mock("../uploadConnectorFile", () => ({ + uploadConnectorFile: vi.fn(), +})); + +const buildRequest = () => + new NextRequest("http://localhost/api/connectors/files", { + method: "POST", + body: JSON.stringify({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + }); + +describe("uploadConnectorFileHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 200 with the flat file descriptor on success", async () => { + vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + vi.mocked(uploadConnectorFile).mockResolvedValue({ + name: "a.png", + mimetype: "image/png", + s3key: "composio/xyz", + }); + + const response = await uploadConnectorFileHandler(buildRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + name: "a.png", + mimetype: "image/png", + s3key: "composio/xyz", + }); + }); + + it("returns the validation/auth error response unchanged", async () => { + const errorResponse = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue(errorResponse); + + const response = await uploadConnectorFileHandler(buildRequest()); + + expect(response).toBe(errorResponse); + expect(uploadConnectorFile).not.toHaveBeenCalled(); + }); + + it("returns 502 when the Composio upload fails", async () => { + vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + vi.mocked(uploadConnectorFile).mockRejectedValue(new Error("storage returned HTTP 404")); + + const response = await uploadConnectorFileHandler(buildRequest()); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(body.error).toContain("storage returned HTTP 404"); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateUploadConnectorFileBody.test.ts b/lib/composio/connectors/__tests__/validateUploadConnectorFileBody.test.ts new file mode 100644 index 000000000..3673348ac --- /dev/null +++ b/lib/composio/connectors/__tests__/validateUploadConnectorFileBody.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateUploadConnectorFileBody } from "../validateUploadConnectorFileBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({})), +})); + +describe("validateUploadConnectorFileBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the validated body for a valid url + toolSlug", () => { + const result = validateUploadConnectorFileBody({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + expect(result).toEqual({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + }); + + it("returns 400 when url is missing", () => { + const result = validateUploadConnectorFileBody({ toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns 400 when url is not a valid URL", () => { + const result = validateUploadConnectorFileBody({ + url: "not-a-url", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns 400 when toolSlug is missing", () => { + const result = validateUploadConnectorFileBody({ url: "https://cdn.example.com/post.png" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateUploadConnectorFileRequest.test.ts b/lib/composio/connectors/__tests__/validateUploadConnectorFileRequest.test.ts new file mode 100644 index 000000000..48544c340 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateUploadConnectorFileRequest.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateUploadConnectorFileRequest } from "../validateUploadConnectorFileRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +const buildRequest = (body: unknown) => + new NextRequest("http://localhost/api/connectors/files", { + method: "POST", + body: JSON.stringify(body), + }); + +describe("validateUploadConnectorFileRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth error when authentication fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + + const result = await validateUploadConnectorFileRequest( + buildRequest({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(401); + }); + + it("returns 400 when the body is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_1", + orgId: null, + authToken: "t", + }); + + const result = await validateUploadConnectorFileRequest(buildRequest({ toolSlug: "X" })); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns 400 (not a throw) when the body is malformed JSON", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_1", + orgId: null, + authToken: "t", + }); + + const malformed = new NextRequest("http://localhost/api/connectors/files", { + method: "POST", + body: "{not json", + }); + + const result = await validateUploadConnectorFileRequest(malformed); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns the validated { url, toolSlug } on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_1", + orgId: null, + authToken: "t", + }); + + const result = await validateUploadConnectorFileRequest( + buildRequest({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + ); + + expect(result).toEqual({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + }); +}); diff --git a/lib/composio/connectors/deriveToolkitSlug.ts b/lib/composio/connectors/deriveToolkitSlug.ts new file mode 100644 index 000000000..869a3c717 --- /dev/null +++ b/lib/composio/connectors/deriveToolkitSlug.ts @@ -0,0 +1,15 @@ +/** + * Derives the Composio toolkit slug from an action slug. + * + * Action slugs are `UPPERCASE_SNAKE_CASE` and prefixed with their toolkit + * (e.g. `LINKEDIN_CREATE_LINKED_IN_POST` → `linkedin`, + * `TWITTER_CREATION_OF_A_POST` → `twitter`). Composio's file upload is scoped + * to a `{ toolSlug, toolkitSlug }`, so we recover the toolkit from the leading + * token of the action slug. + * + * @param toolSlug - The action slug (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`) + * @returns The lowercase toolkit slug (e.g. `linkedin`) + */ +export function deriveToolkitSlug(toolSlug: string): string { + return toolSlug.split("_")[0].toLowerCase(); +} diff --git a/lib/composio/connectors/uploadConnectorFile.ts b/lib/composio/connectors/uploadConnectorFile.ts new file mode 100644 index 000000000..d6837732b --- /dev/null +++ b/lib/composio/connectors/uploadConnectorFile.ts @@ -0,0 +1,42 @@ +import { getComposioClient } from "../client"; +import { deriveToolkitSlug } from "./deriveToolkitSlug"; + +/** + * A Composio file descriptor, ready to embed in a `file_uploadable` action + * field (e.g. `LINKEDIN_CREATE_LINKED_IN_POST.images[]`). + */ +export interface UploadedConnectorFile { + name: string; + mimetype: string; + s3key: string; +} + +/** + * Stages an image into Composio storage and returns its `{ name, mimetype, s3key }` + * descriptor. + * + * Composio's SDK fetches the URL server-side, uploads the bytes to storage + * (deduplicated by content hash), and returns a storage key that can then be + * passed verbatim into a `file_uploadable` action parameter. The upload is + * scoped to the action's tool/toolkit, derived from `toolSlug`. + * + * @param params.url - Publicly reachable image URL to stage + * @param params.toolSlug - The action slug the file will be attached to + * (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`) + * @returns The Composio file descriptor + * @throws when Composio fails to fetch or store the file (surfaced as 502 upstream) + */ +export async function uploadConnectorFile(params: { + url: string; + toolSlug: string; +}): Promise { + const composio = await getComposioClient(); + + const { name, mimetype, s3key } = await composio.files.upload({ + file: params.url, + toolSlug: params.toolSlug, + toolkitSlug: deriveToolkitSlug(params.toolSlug), + }); + + return { name, mimetype, s3key }; +} diff --git a/lib/composio/connectors/uploadConnectorFileHandler.ts b/lib/composio/connectors/uploadConnectorFileHandler.ts new file mode 100644 index 000000000..6e4156d74 --- /dev/null +++ b/lib/composio/connectors/uploadConnectorFileHandler.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateUploadConnectorFileRequest } from "./validateUploadConnectorFileRequest"; +import { uploadConnectorFile } from "./uploadConnectorFile"; + +/** + * Handler for POST /api/connectors/files. + * + * Stages an image (by public URL) into Composio storage and returns a flat + * `{ success, name, mimetype, s3key }` descriptor. The caller passes + * `{ name, mimetype, s3key }` into a `file_uploadable` action parameter + * (e.g. `parameters.images[]` for `LINKEDIN_CREATE_LINKED_IN_POST`) on + * POST /api/connectors/actions. A failure to fetch or store the image surfaces + * as 502 (upstream). + * + * @param request - The incoming request + * @returns A 200 response with `{ success, name, mimetype, s3key }`, or an error. + */ +export async function uploadConnectorFileHandler(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + const validated = await validateUploadConnectorFileRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { name, mimetype, s3key } = await uploadConnectorFile(validated); + + return NextResponse.json({ success: true, name, mimetype, s3key }, { status: 200, headers }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to upload connector file"; + console.error("Connector file upload error:", error); + return NextResponse.json({ error: message }, { status: 502, headers }); + } +} diff --git a/lib/composio/connectors/validateUploadConnectorFileBody.ts b/lib/composio/connectors/validateUploadConnectorFileBody.ts new file mode 100644 index 000000000..c376ef997 --- /dev/null +++ b/lib/composio/connectors/validateUploadConnectorFileBody.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const uploadConnectorFileBodySchema = z.object({ + url: z + .string({ message: "url is required" }) + .url("url must be a valid URL (a publicly reachable image link)"), + toolSlug: z + .string({ message: "toolSlug is required" }) + .min(1, "toolSlug cannot be empty (e.g., 'LINKEDIN_CREATE_LINKED_IN_POST')"), +}); + +export type UploadConnectorFileBody = z.infer; + +/** + * Validates request body for POST /api/connectors/files. + * + * Body shape: { url, toolSlug }. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateUploadConnectorFileBody( + body: unknown, +): NextResponse | UploadConnectorFileBody { + const result = uploadConnectorFileBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/composio/connectors/validateUploadConnectorFileRequest.ts b/lib/composio/connectors/validateUploadConnectorFileRequest.ts new file mode 100644 index 000000000..bf1791c9b --- /dev/null +++ b/lib/composio/connectors/validateUploadConnectorFileRequest.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateUploadConnectorFileBody } from "./validateUploadConnectorFileBody"; + +/** + * Validated params for staging a connector file. + */ +export interface UploadConnectorFileParams { + url: string; + toolSlug: string; +} + +/** + * Validates the full POST /api/connectors/files request. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) — gates the endpoint + * 2. Body validation ({ url, toolSlug }) + * + * The Composio upload is scoped by tool/toolkit, not by connected account, so + * no `account_id` is accepted or used here — authentication just gates access. + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateUploadConnectorFileRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const body = await safeParseJson(request); + const validated = validateUploadConnectorFileBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + return { url: validated.url, toolSlug: validated.toolSlug }; +} From 2a7af682fa421bdb646489b499bbe1d369ba8e85 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 23 Jun 2026 07:41:21 -0500 Subject: [PATCH 11/27] feat(artists): account_id override for DELETE /api/artists/{id} (#693) Parse an optional account_id from the request body and thread it into validateAuthContext(request, { accountId }), so a caller with access to multiple accounts (org members / Recoup admins) can delete an artist in another account's context. The resolved account is used for the checkAccountArtistAccess check; a non-admin passing an inaccessible account is still rejected by canAccessAccount (403). Mirrors the existing override pattern on POST /api/artists. chat#1811 Co-authored-by: Claude Opus 4.8 (1M context) --- .../validateDeleteArtistRequest.test.ts | 53 ++++++++++++++++++- lib/artists/validateDeleteArtistRequest.ts | 27 +++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/lib/artists/__tests__/validateDeleteArtistRequest.test.ts b/lib/artists/__tests__/validateDeleteArtistRequest.test.ts index c0585f9f2..b39790dbe 100644 --- a/lib/artists/__tests__/validateDeleteArtistRequest.test.ts +++ b/lib/artists/__tests__/validateDeleteArtistRequest.test.ts @@ -55,7 +55,7 @@ describe("validateDeleteArtistRequest", () => { const result = await validateDeleteArtistRequest(request, validArtistId); expect(result).toBe(authError); - expect(validateAuthContext).toHaveBeenCalledWith(request); + expect(validateAuthContext).toHaveBeenCalledWith(request, { accountId: undefined }); }); it("returns 404 when the artist does not exist", async () => { @@ -125,4 +125,55 @@ describe("validateDeleteArtistRequest", () => { requesterAccountId: authenticatedAccountId, }); }); + + describe("account_id override", () => { + const overrideAccountId = "770e8400-e29b-41d4-a716-446655440000"; + + it("passes the account_id override to validateAuthContext and resolves against it", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: overrideAccountId, + authToken: "test-token", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([{ id: validArtistId }] as never); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ account_id: overrideAccountId }), + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: overrideAccountId, + }); + expect(checkAccountArtistAccess).toHaveBeenCalledWith(overrideAccountId, validArtistId); + expect(result).toEqual({ + artistId: validArtistId, + requesterAccountId: overrideAccountId, + }); + }); + + it("returns 400 when account_id is not a valid UUID", async () => { + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ account_id: "not-a-uuid" }), + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + }); }); diff --git a/lib/artists/validateDeleteArtistRequest.ts b/lib/artists/validateDeleteArtistRequest.ts index a69832e45..da237bca8 100644 --- a/lib/artists/validateDeleteArtistRequest.ts +++ b/lib/artists/validateDeleteArtistRequest.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; @@ -10,9 +12,17 @@ export interface DeleteArtistRequest { requesterAccountId: string; } +const deleteArtistBodySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + /** * Validates DELETE /api/artists/{id} path params and authentication. * + * Accepts an optional `account_id` in the request body so a caller with access + * to multiple accounts (org members or Recoup admins) can delete an artist in + * another account's context. The override is authorized by `validateAuthContext`. + * * @param request - The incoming request * @param id - The artist account ID from the route * @returns The validated artist ID plus requester context, or a NextResponse error @@ -26,7 +36,22 @@ export async function validateDeleteArtistRequest( return validatedParams; } - const authResult = await validateAuthContext(request); + const body = await safeParseJson(request); + const bodyResult = deleteArtistBodySchema.safeParse(body); + if (!bodyResult.success) { + const firstError = bodyResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const authResult = await validateAuthContext(request, { + accountId: bodyResult.data.account_id, + }); if (authResult instanceof NextResponse) { return authResult; } From 8b6a3bb763c3fc40054589b660b890121b34dd94 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 23 Jun 2026 09:14:43 -0500 Subject: [PATCH 12/27] =?UTF-8?q?feat(chats):=20admins=20(RECOUP=5FORG)=20?= =?UTF-8?q?can=20access=20any=20chat=20=E2=80=94=20read=20+=20write=20(#69?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chats): account_id override for GET /api/chats/{id}/messages Parse an optional account_id (or camelCase accountId) query param in validateGetChatMessagesQuery, validate it as a UUID, and thread it into validateChatAccess via a new optional options arg. validateChatAccess forwards it to validateAuthContext(request, { accountId }) and resolves room access against the overridden account, so a caller with access to multiple accounts (org members / Recoup admins) can read another account's chat messages. A non-admin passing an inaccessible account is still rejected by canAccessAccount (403). The override is opt-in per call site: only validateGetChatMessagesQuery passes it, so the other validateChatAccess callers are unchanged. chat#1811 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chats): admin bypass (not account_id param) for GET messages Aligns GET /api/chats/{id}/messages with the shipped docs contract — docs#247 rolled back the account_id query param. The chat is identified by the path id and the owner is resolved server-side, so no param is needed. Instead, validateChatAccess gains an opt-in `allowAdmin` flag that grants RECOUP_ORG admins access to any room (mirrors checkAccountArtistAccess). Only the messages read path opts in; chat mutations (update/delete/copy) stay ownership-gated, so admin write access is not silently broadened. - drop account_id/accountId query parsing from validateGetChatMessagesQuery - validateChatAccess: remove accountId override; add allowAdmin + checkIsAdmin bypass - tests: admin bypass grants access; non-admin still 403 even with allowAdmin; mutation paths never consult admin status - mock checkIsAdmin in getChatArtistHandler.test.ts (now a transitive dep) Refs recoupable/chat#1811 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chats): drop allowAdmin flag — admins access any chat (read + write) YAGNI/KISS per internal review: RECOUP_ORG admins already have broad cross-account power (delete any artist, read any account), and chat ops are resource-scoped by chatId, so an unconditional admin bypass is the coherent model. Removes the opt-in flag entirely. The admin check now runs ONLY after the ownership check fails, so the common owner path never pays the extra checkIsAdmin lookup (better than both the flag and a top-of-function bypass). Applies across all validateChatAccess call sites (messages + getChatArtist reads; update/delete-trailing/copy mutations), so admins can read and write any account's chats; non-admins are unchanged (403). Refs recoupable/chat#1811 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chats): revert validateGetChatMessagesQuery (no change needed) The admin bypass lives entirely in validateChatAccess, which the messages endpoint already delegates to — so validateGetChatMessagesQuery needs no change. Reverts the doc-only edit and the redundant delegation test to keep the PR scoped to validateChatAccess. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/getChatArtistHandler.test.ts | 4 ++ .../__tests__/validateChatAccess.test.ts | 60 +++++++++++++++++++ lib/chats/validateChatAccess.ts | 33 +++++----- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/lib/chats/__tests__/getChatArtistHandler.test.ts b/lib/chats/__tests__/getChatArtistHandler.test.ts index c88ce650d..7bbf0d388 100644 --- a/lib/chats/__tests__/getChatArtistHandler.test.ts +++ b/lib/chats/__tests__/getChatArtistHandler.test.ts @@ -17,6 +17,10 @@ vi.mock("@/lib/chats/buildGetChatsParams", () => ({ buildGetChatsParams: vi.fn(), })); +vi.mock("@/lib/admins/checkIsAdmin", () => ({ + checkIsAdmin: vi.fn(() => Promise.resolve(false)), +})); + const createRequest = () => new NextRequest("http://localhost/api/chats/chat-id/artist"); describe("getChatArtistHandler", () => { diff --git a/lib/chats/__tests__/validateChatAccess.test.ts b/lib/chats/__tests__/validateChatAccess.test.ts index 4c9255446..cac17ccfa 100644 --- a/lib/chats/__tests__/validateChatAccess.test.ts +++ b/lib/chats/__tests__/validateChatAccess.test.ts @@ -4,6 +4,7 @@ import { validateChatAccess } from "../validateChatAccess"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; +import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -21,6 +22,10 @@ vi.mock("@/lib/chats/buildGetChatsParams", () => ({ buildGetChatsParams: vi.fn(), })); +vi.mock("@/lib/admins/checkIsAdmin", () => ({ + checkIsAdmin: vi.fn(), +})); + describe("validateChatAccess", () => { const roomId = "123e4567-e89b-12d3-a456-426614174000"; const accountId = "123e4567-e89b-12d3-a456-426614174001"; @@ -30,6 +35,7 @@ describe("validateChatAccess", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(checkIsAdmin).mockResolvedValue(false); }); it("returns 400 when roomId is invalid uuid", async () => { @@ -159,4 +165,58 @@ describe("validateChatAccess", () => { const result = await validateChatAccess(request, roomId); expect(result).toEqual({ roomId, room, accountId }); }); + + it("grants a Recoup admin access to a room they don't own (admin checked after ownership fails)", async () => { + const room = { + id: roomId, + account_id: "another-account", + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue(room); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const result = await validateChatAccess(request, roomId); + + expect(buildGetChatsParams).toHaveBeenCalled(); + expect(checkIsAdmin).toHaveBeenCalledWith(accountId); + expect(result).toEqual({ roomId, room, accountId: "another-account" }); + }); + + it("does not consult admin status when the caller owns the room", async () => { + const room = { + id: roomId, + account_id: accountId, + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue(room); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const result = await validateChatAccess(request, roomId); + + expect(checkIsAdmin).not.toHaveBeenCalled(); + expect(result).toEqual({ roomId, room, accountId }); + }); }); diff --git a/lib/chats/validateChatAccess.ts b/lib/chats/validateChatAccess.ts index 30f89e5db..de663feaf 100644 --- a/lib/chats/validateChatAccess.ts +++ b/lib/chats/validateChatAccess.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; import type { Tables } from "@/types/database.types"; @@ -18,6 +19,11 @@ const chatIdSchema = z.string().uuid("id must be a valid UUID"); /** * Validates that the authenticated caller can access a chat room. * + * The room is fully identified by `roomId`, so no account override is accepted. + * Access is granted to the room's owner; a Recoup admin (`RECOUP_ORG_ID` member) + * may access any room. The admin check runs only when the ownership check fails, + * so the common owner path never pays the extra lookup. + * * @param request - The incoming request (used for auth context) * @param roomId - The room/chat UUID to validate access for * @returns NextResponse on auth/access failure, or validated access data @@ -49,23 +55,22 @@ export async function validateChatAccess( ); } - const { params, error } = await buildGetChatsParams({ - account_id: accountId, - }); + const { params } = await buildGetChatsParams({ account_id: accountId }); - if (!params) { - return NextResponse.json( - { status: "error", error: error ?? "Access denied" }, - { status: 403, headers: getCorsHeaders() }, - ); + const isOwner = !!params && !!room.account_id && params.account_ids.includes(room.account_id); + if (isOwner) { + return { roomId: room.id, room, accountId }; } - if (!room.account_id || !params.account_ids.includes(room.account_id)) { - return NextResponse.json( - { status: "error", error: "Access denied to this chat" }, - { status: 403, headers: getCorsHeaders() }, - ); + // Non-owner: a Recoup admin may access any chat (read or write). The owner is + // resolved server-side, so no account_id input is needed. Checked only here so + // the owner path above never pays the lookup. + if (await checkIsAdmin(accountId)) { + return { roomId: room.id, room, accountId: room.account_id ?? accountId }; } - return { roomId: room.id, room, accountId }; + return NextResponse.json( + { status: "error", error: "Access denied to this chat" }, + { status: 403, headers: getCorsHeaders() }, + ); } From 03e0ef5fbeaa16b65b1561f4d1039dbb5f8e4e3b Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Wed, 24 Jun 2026 09:00:50 -0500 Subject: [PATCH 13/27] Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) (#700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): ephemeral, account-scoped api keys (chat#1813) Foundation for the async chat-generation migration: the headless/scheduled path has no client Privy session to forward into the sandbox and must not put the long-lived service key into model-driven bash. It instead mints a short-lived, account-scoped recoup_sk_ key per run and deletes it on completion. - lib/keys/mintEphemeralAccountKey: generate+hash+insert a recoup_sk_ key with an expires_at (default 15m TTL); returns { rawKey, keyId } for injection + cleanup. - lib/keys/isApiKeyExpired: pure TTL check (NULL/unparseable = never expires). - getApiKeyAccountId: reject a key whose expires_at has passed (401). Backward compatible — existing long-lived keys have NULL expiry. - insertApiKey + database.types: carry the new account_api_keys.expires_at column. Depends on database#36 (adds the column). Security-sensitive (touches the api-key auth path) — please review the expiry-enforcement diff. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(auth): scope PR to expiry enforcement; defer key minting Remove mintEphemeralAccountKey + its test and revert the insertApiKey expires_at writer change. Both are orphaned in this PR — mint has no caller anywhere, and insertApiKey's expires_at param is only ever passed by mint. They belong with the re-point PR (handleChatGenerate) that actually mints + injects + deletes the key, so this PR stays a complete, testable slice: enforce expires_at on x-api-key auth (getApiKeyAccountId + isApiKeyExpired). The minting code + its wiring spec are preserved in the tracking issue (recoupable/chat#1813). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- lib/auth/__tests__/getApiKeyAccountId.test.ts | 54 +++++++++++++++++++ lib/auth/getApiKeyAccountId.ts | 7 ++- lib/keys/__tests__/isApiKeyExpired.test.ts | 24 +++++++++ lib/keys/isApiKeyExpired.ts | 15 ++++++ types/database.types.ts | 3 ++ 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 lib/auth/__tests__/getApiKeyAccountId.test.ts create mode 100644 lib/keys/__tests__/isApiKeyExpired.test.ts create mode 100644 lib/keys/isApiKeyExpired.ts diff --git a/lib/auth/__tests__/getApiKeyAccountId.test.ts b/lib/auth/__tests__/getApiKeyAccountId.test.ts new file mode 100644 index 000000000..e2a9aa7cc --- /dev/null +++ b/lib/auth/__tests__/getApiKeyAccountId.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); +vi.mock("@/lib/keys/hashApiKey", () => ({ hashApiKey: (k: string) => `hashed_${k}` })); +vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "test_secret" })); +vi.mock("@/lib/supabase/account_api_keys/selectAccountApiKeys", () => ({ + selectAccountApiKeys: vi.fn(), +})); + +function req(apiKey?: string) { + const headers = new Headers(); + if (apiKey) headers.set("x-api-key", apiKey); + return new NextRequest("https://x.test/api", { headers }); +} + +const baseRow = { + id: "k", + account: "acc-1", + key_hash: "hashed_recoup_sk_x", + name: "n", + last_used: null, + created_at: "2026-01-01T00:00:00Z", +}; + +describe("getApiKeyAccountId", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns accountId for a non-expiring key (expires_at null)", async () => { + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: null }]); + expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1"); + }); + + it("returns accountId for a future expiry", async () => { + const future = new Date(Date.now() + 60_000).toISOString(); + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: future }]); + expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1"); + }); + + it("rejects an expired key with 401", async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: past }]); + const res = await getApiKeyAccountId(req("recoup_sk_x")); + expect(res).toBeInstanceOf(Response); + expect((res as Response).status).toBe(401); + }); + + it("401 when no x-api-key header", async () => { + const res = await getApiKeyAccountId(req()); + expect((res as Response).status).toBe(401); + }); +}); diff --git a/lib/auth/getApiKeyAccountId.ts b/lib/auth/getApiKeyAccountId.ts index 49af4106a..a5dce9ed6 100644 --- a/lib/auth/getApiKeyAccountId.ts +++ b/lib/auth/getApiKeyAccountId.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired"; import { PRIVY_PROJECT_SECRET } from "@/lib/const"; import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; @@ -45,9 +46,11 @@ export async function getApiKeyAccountId(request: NextRequest): Promise { + const now = Date.parse("2026-06-23T00:00:00Z"); + + it("treats null/undefined expiry as never-expiring", () => { + expect(isApiKeyExpired(null, now)).toBe(false); + expect(isApiKeyExpired(undefined, now)).toBe(false); + }); + + it("is false for a future expiry", () => { + expect(isApiKeyExpired("2026-06-23T01:00:00Z", now)).toBe(false); + }); + + it("is true at or past the expiry", () => { + expect(isApiKeyExpired("2026-06-22T23:59:59Z", now)).toBe(true); + expect(isApiKeyExpired("2026-06-23T00:00:00Z", now)).toBe(true); + }); + + it("treats an unparseable expiry as non-expiring (never lock out)", () => { + expect(isApiKeyExpired("not-a-date", now)).toBe(false); + }); +}); diff --git a/lib/keys/isApiKeyExpired.ts b/lib/keys/isApiKeyExpired.ts new file mode 100644 index 000000000..57e7632ec --- /dev/null +++ b/lib/keys/isApiKeyExpired.ts @@ -0,0 +1,15 @@ +/** + * Whether an api key's `expires_at` has passed. NULL/undefined = never expires. + * An unparseable value is treated as non-expiring so a bad row can't lock a + * caller out. Used by api auth to reject ephemeral keys past their TTL + * (recoupable/chat#1813). + */ +export function isApiKeyExpired( + expiresAt: string | null | undefined, + now: number = Date.now(), +): boolean { + if (!expiresAt) return false; + const exp = Date.parse(expiresAt); + if (Number.isNaN(exp)) return false; + return exp <= now; +} diff --git a/types/database.types.ts b/types/database.types.ts index 1771da89f..872750b72 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -12,6 +12,7 @@ export type Database = { Row: { account: string | null; created_at: string; + expires_at: string | null; id: string; key_hash: string | null; last_used: string | null; @@ -20,6 +21,7 @@ export type Database = { Insert: { account?: string | null; created_at?: string; + expires_at?: string | null; id?: string; key_hash?: string | null; last_used?: string | null; @@ -28,6 +30,7 @@ export type Database = { Update: { account?: string | null; created_at?: string; + expires_at?: string | null; id?: string; key_hash?: string | null; last_used?: string | null; From 78bd71b28ed13450ffcb6df74b18147687a8fcce Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Wed, 24 Jun 2026 09:27:37 -0500 Subject: [PATCH 14/27] refactor(chat): extract shared buildRunAgentInput (chat#1813) (#701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the RunAgentWorkflowInput construction out of handleChatWorkflowStream into a pure, shared builder so the interactive (/api/chat/workflow) and the upcoming headless (/api/chat/generate) callers construct workflow input identically. Repo identifiers and the recoup org id are derived from clone_url inside the builder — one source of truth, no caller duplication. Behavior-preserving: the interactive handler now delegates to buildRunAgentInput; existing handleChatWorkflowStream tests stay green (20), plus 4 new builder tests. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/chat/__tests__/buildRunAgentInput.test.ts | 61 +++++++++++++++++ lib/chat/buildRunAgentInput.ts | 66 +++++++++++++++++++ lib/chat/handleChatWorkflowStream.ts | 46 ++++--------- 3 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 lib/chat/__tests__/buildRunAgentInput.test.ts create mode 100644 lib/chat/buildRunAgentInput.ts diff --git a/lib/chat/__tests__/buildRunAgentInput.test.ts b/lib/chat/__tests__/buildRunAgentInput.test.ts new file mode 100644 index 000000000..45af4f7cc --- /dev/null +++ b/lib/chat/__tests__/buildRunAgentInput.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { extractOrgId } from "@/lib/recoupable/extractOrgId"; + +vi.mock("@/lib/github/parseGitHubRepoIdentifiers", () => ({ + parseGitHubRepoIdentifiers: vi.fn(() => ({ owner: "o", repo: "r" })), +})); +vi.mock("@/lib/recoupable/extractOrgId", () => ({ + extractOrgId: vi.fn(() => "org-1"), +})); + +const base = { + messages: [{ id: "m1", role: "user", parts: [] }] as never, + chatId: "chat-1", + sessionId: "sess-1", + accountId: "acc-1", + modelId: "anthropic/claude-haiku-4.5", + sessionTitle: "Weekly report", + cloneUrl: "https://github.com/o/r.git", + sandboxState: { type: "vercel", sandboxName: "sb-1" } as never, + workingDirectory: "/work", + skills: [] as never, +}; + +describe("buildRunAgentInput", () => { + beforeEach(() => vi.clearAllMocks()); + + it("assembles the workflow input, deriving repo ids + org id from cloneUrl", () => { + const input = buildRunAgentInput(base); + expect(input.chatId).toBe("chat-1"); + expect(input.sessionId).toBe("sess-1"); + expect(input.accountId).toBe("acc-1"); + expect(input.modelId).toBe("anthropic/claude-haiku-4.5"); + expect(input.sessionTitle).toBe("Weekly report"); + expect(input.repoOwner).toBe("o"); + expect(input.repoName).toBe("r"); + expect(input.agentContext.recoupOrgId).toBe("org-1"); + expect(input.agentContext.sandbox).toEqual({ + state: { type: "vercel", sandboxName: "sb-1" }, + workingDirectory: "/work", + }); + }); + + it("includes recoupAccessToken when provided", () => { + const input = buildRunAgentInput({ ...base, recoupAccessToken: "tok-123" }); + expect(input.agentContext.recoupAccessToken).toBe("tok-123"); + }); + + it("omits recoupAccessToken entirely when absent", () => { + const input = buildRunAgentInput(base); + expect("recoupAccessToken" in input.agentContext).toBe(false); + }); + + it("leaves recoupOrgId undefined when cloneUrl is null (no org derivation)", () => { + const input = buildRunAgentInput({ ...base, cloneUrl: null }); + expect(input.agentContext.recoupOrgId).toBeUndefined(); + expect(extractOrgId).not.toHaveBeenCalled(); + expect(parseGitHubRepoIdentifiers).toHaveBeenCalledWith(null); + }); +}); diff --git a/lib/chat/buildRunAgentInput.ts b/lib/chat/buildRunAgentInput.ts new file mode 100644 index 000000000..95b0df423 --- /dev/null +++ b/lib/chat/buildRunAgentInput.ts @@ -0,0 +1,66 @@ +import type { UIMessage } from "ai"; +import type { RunAgentWorkflowInput } from "@/app/lib/workflows/runAgentWorkflow"; +import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext"; +import type { VercelState } from "@/lib/sandbox/vercel/state"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { extractOrgId } from "@/lib/recoupable/extractOrgId"; + +export type BuildRunAgentInputParams = { + messages: UIMessage[]; + chatId: string; + sessionId: string; + accountId: string; + modelId: string; + sessionTitle?: string; + /** `session.clone_url` — the single source for repo ids + recoup org id. */ + cloneUrl: string | null; + sandboxState: VercelState; + workingDirectory: string; + skills: DurableAgentContext["skills"]; + /** + * Short-lived bearer for in-sandbox recoup-api calls: the user's Privy JWT + * (interactive `/api/chat/workflow`) or an ephemeral account key (headless + * `/api/chat/generate`). Omitted when absent so the service key never leaks. + */ + recoupAccessToken?: string; +}; + +/** + * Build the durable `RunAgentWorkflowInput` shared by the interactive and + * headless callers, so both construct workflow input identically + * (recoupable/chat#1813). Repo identifiers and the recoup org id are both + * derived from `cloneUrl` here — one source of truth, no caller duplication. + */ +export function buildRunAgentInput({ + messages, + chatId, + sessionId, + accountId, + modelId, + sessionTitle, + cloneUrl, + sandboxState, + workingDirectory, + skills, + recoupAccessToken, +}: BuildRunAgentInputParams): RunAgentWorkflowInput { + const repoIds = parseGitHubRepoIdentifiers(cloneUrl); + const recoupOrgId = cloneUrl ? (extractOrgId(cloneUrl) ?? undefined) : undefined; + + return { + messages, + chatId, + sessionId, + accountId, + modelId, + sessionTitle, + repoOwner: repoIds?.owner, + repoName: repoIds?.repo, + agentContext: { + sandbox: { state: sandboxState, workingDirectory }, + recoupOrgId, + skills, + ...(recoupAccessToken ? { recoupAccessToken } : {}), + }, + }; +} diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 8d61ce96e..fab562e1c 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -14,8 +14,7 @@ import { persistLatestUserMessage } from "@/lib/chat/persistLatestUserMessage"; import { errorResponse } from "@/lib/networking/errorResponse"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; -import { extractOrgId } from "@/lib/recoupable/extractOrgId"; -import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; import type { VercelState } from "@/lib/sandbox/vercel/state"; @@ -92,9 +91,6 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise Date: Wed, 24 Jun 2026 16:09:05 -0500 Subject: [PATCH 15/27] Re-point POST /api/chat/generate onto runAgentWorkflow + ephemeral key (chat#1813) (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat): re-point /api/chat/generate onto runAgentWorkflow (chat#1813) Async chat generation now runs on the SAME durable runAgentWorkflow as interactive /api/chat instead of the synchronous legacy ToolLoopAgent. POST /api/chat/generate provisions a headless session + active sandbox, mints a short-lived account-scoped recoup_sk_ key for in-sandbox recoup-api calls, builds the shared workflow input via buildRunAgentInput, and start()s the run — returning { runId } with 202 immediately. Generation, message persistence, the credit charge, and key revocation happen server-side inside the workflow. - lib/keys/mintEphemeralAccountKey + insertApiKey expires_at writer (re-added from the deferred half of #700; minting now has its only consumer). - lib/chat/generate/validateGenerateRequest — x-api-key auth + prompt/messages normalization to UIMessage[]. - lib/chat/generate/provisionGenerateSession — ensurePersonalRepo → insertSession → insertChat → connectSandbox → updateSession(active) → discoverSkills. - lib/chat/handleChatGenerate — orchestrates provision → mint → start; revokes the key if the run never starts. - Ephemeral key injected as recoupAccessToken + threaded as agentContext.ephemeralKeyId; runAgentWorkflow's finally deletes it on run end (deleteEphemeralKeyStep). The ~15m expires_at TTL (enforced by #700) is the backstop. - Matches docs#249 (202 { runId } contract). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(chat): return { runId, chatId, sessionId } from /api/chat/generate The workflow runId alone can't be resolved back to the chat output. Return the persisted-output identifiers too so a caller can read the result later (GET /api/chat/{chatId}/stream, or the chat's persisted messages) — turning the endpoint from fire-and-forget-only into a proper async-job contract. The scheduled task still ignores the body. (chat#1813, review follow-up.) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): rename POST /api/chat/generate → POST /api/chat/runs REST cleanup (chat#1813): the endpoint starts a *run*, so it's modeled as a run resource, not a `generate` verb. Removes /generate entirely (no alias). - Route app/api/chat/generate → app/api/chat/runs; handler handleChatGenerate → handleStartChatRun. Add a Location header at /api/chat/runs/{runId}. - Update path strings in comments/JSDoc to /api/chat/runs. Also addresses cubic review on this PR: - validateGenerateRequest: trim prompt before the presence check (reject blank). - handleStartChatRun: standardized 500 body "Internal server error". - validateGenerateRequest test: use a schema-valid field so the "exactly one of prompt/messages" case is exercised for the right reason; add a whitespace-prompt test. (Internal helper names — validateGenerateRequest/provisionGenerateSession — keep "generate" as it describes the operation; renaming is out of scope.) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): drop dead roomId from the request schema roomId was accepted-but-ignored on the re-pointed endpoint (it mints its own session+chat per run and returns chatId/sessionId). Nothing sends it anymore (tasks#152 stopped), and Zod strips unknown keys regardless — so remove it from the schema to keep docs↔api in sync. excludeTools was already gone. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): remove topic param to match /api/chat /api/chat takes no session-title param, so /api/chat/runs shouldn't either. The endpoint provisions its own session with a default title; drop topic from the request schema and the GenerateRequest type. (chat#1813 review) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(chat/runs): implement GET /api/chat/runs/{runId} status endpoint Brings the api to parity with the merged docs#249, which documented the run- status endpoint. Wraps the durable workflow's getRun(runId).status and returns { runId, status } (normalized to queued|running|completed|failed|cancelled). 404 when the run is unknown; x-api-key auth. Returns { runId, status } rather than the documented chatId/sessionId: getRun exposes only status, and there's no durable runId→chat mapping (the caller already holds chatId/sessionId from the 202 start response). Docs reconciled to match; full chatId/sessionId + per-run ownership would need a chats.last_run_id column (follow-up). (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): SRP + DRY — share session/sandbox provisioning libs Addresses review on api#704: SRP — extract normalizeRunStatus into its own file (one exported fn per file). DRY — the headless provisionGenerateSession duplicated the interactive flow. Extract the shared blocks and use them in both paths: - lib/sessions/createSessionWithInitialChat — ensurePersonalRepo → insertSession → insertChat with rollback. Used by createSessionHandler (POST /api/sessions) AND provisionGenerateSession. Also fixes the headless rollback gap (cubic P2). - lib/sandbox/markSessionSandboxActive — bind sandbox state to a session + mark active. Used by createSandboxHandler (POST /api/sandbox) AND provisionGenerateSession. The sandbox connectSandbox call itself is left in each caller: the interactive createSandboxHandler interleaves org-snapshot warm-boot + one-shot (no-session) provisioning + skill-install + lifecycle-kick that the lean headless path intentionally omits, so forcing a shared connect would couple unrelated concerns. Behavior-preserving: full lib/sessions + lib/sandbox suites green; new unit tests for the 3 extracted fns. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): rename lib/chat/generate → lib/chat/runs (match the endpoint) The endpoint was renamed /api/chat/generate → /api/chat/runs, but the internal helpers kept "generate" — pointing at a removed concept, and split across two dirs (handleStartChatRun lived in lib/chat/, its helpers in lib/chat/generate/). Pure rename, no behavior change: - lib/chat/generate/ → lib/chat/runs/ (handleStartChatRun + its test moved in too) - validateGenerateRequest → validateChatRunRequest (file + symbol) - provisionGenerateSession → provisionRunSession (file + symbol) - ProvisionedGenerateSession → ProvisionedRunSession - generateBodySchema → chatRunBodySchema, GenerateRequest → ChatRunRequest - DEFAULT_GENERATE_MODEL_ID → DEFAULT_RUN_MODEL_ID - updated JSDoc refs in the shared createSandboxHandler / markSessionSandboxActive / createSessionWithInitialChat git mv preserves history. lib/chat/generateChatTitle (unrelated) left untouched. Feature suites green (126), tsc + lint clean. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/chat/generate/route.ts | 49 -- app/api/chat/runs/[runId]/route.ts | 42 ++ app/api/chat/runs/route.ts | 55 ++ .../__tests__/runAgentWorkflow.test.ts | 35 ++ app/lib/workflows/deleteEphemeralKeyStep.ts | 25 + app/lib/workflows/runAgentWorkflow.ts | 13 +- lib/agent/tools/AgentContext.ts | 9 + lib/chat/__tests__/handleChatGenerate.test.ts | 582 ------------------ lib/chat/buildRunAgentInput.ts | 9 +- lib/chat/handleChatGenerate.ts | 77 --- .../__tests__/handleChatRunStatus.test.ts | 67 ++ .../runs/__tests__/handleStartChatRun.test.ts | 124 ++++ .../runs/__tests__/normalizeRunStatus.test.ts | 25 + .../__tests__/validateChatRunRequest.test.ts | 87 +++ lib/chat/runs/handleChatRunStatus.ts | 35 ++ lib/chat/runs/handleStartChatRun.ts | 96 +++ lib/chat/runs/normalizeRunStatus.ts | 34 + lib/chat/runs/provisionRunSession.ts | 98 +++ lib/chat/runs/validateChatRunRequest.ts | 88 +++ .../__tests__/mintEphemeralAccountKey.test.ts | 44 ++ lib/keys/mintEphemeralAccountKey.ts | 45 ++ .../markSessionSandboxActive.test.ts | 36 ++ lib/sandbox/createSandboxHandler.ts | 24 +- lib/sandbox/markSessionSandboxActive.ts | 30 + .../createSessionWithInitialChat.test.ts | 63 ++ lib/sessions/createSessionHandler.ts | 66 +- lib/sessions/createSessionWithInitialChat.ts | 66 ++ lib/supabase/account_api_keys/insertApiKey.ts | 3 + 28 files changed, 1155 insertions(+), 772 deletions(-) delete mode 100644 app/api/chat/generate/route.ts create mode 100644 app/api/chat/runs/[runId]/route.ts create mode 100644 app/api/chat/runs/route.ts create mode 100644 app/lib/workflows/deleteEphemeralKeyStep.ts delete mode 100644 lib/chat/__tests__/handleChatGenerate.test.ts delete mode 100644 lib/chat/handleChatGenerate.ts create mode 100644 lib/chat/runs/__tests__/handleChatRunStatus.test.ts create mode 100644 lib/chat/runs/__tests__/handleStartChatRun.test.ts create mode 100644 lib/chat/runs/__tests__/normalizeRunStatus.test.ts create mode 100644 lib/chat/runs/__tests__/validateChatRunRequest.test.ts create mode 100644 lib/chat/runs/handleChatRunStatus.ts create mode 100644 lib/chat/runs/handleStartChatRun.ts create mode 100644 lib/chat/runs/normalizeRunStatus.ts create mode 100644 lib/chat/runs/provisionRunSession.ts create mode 100644 lib/chat/runs/validateChatRunRequest.ts create mode 100644 lib/keys/__tests__/mintEphemeralAccountKey.test.ts create mode 100644 lib/keys/mintEphemeralAccountKey.ts create mode 100644 lib/sandbox/__tests__/markSessionSandboxActive.test.ts create mode 100644 lib/sandbox/markSessionSandboxActive.ts create mode 100644 lib/sessions/__tests__/createSessionWithInitialChat.test.ts create mode 100644 lib/sessions/createSessionWithInitialChat.ts diff --git a/app/api/chat/generate/route.ts b/app/api/chat/generate/route.ts deleted file mode 100644 index 0176d0eee..000000000 --- a/app/api/chat/generate/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleChatGenerate } from "@/lib/chat/handleChatGenerate"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/chat/generate - * - * Non-streaming chat endpoint that processes messages and returns a JSON response. - * - * Authentication: x-api-key header required. - * The account ID is inferred from the API key. - * - * Request body: - * - messages: Array of chat messages (mutually exclusive with prompt) - * - prompt: String prompt (mutually exclusive with messages) - * - roomId: Optional UUID of the chat room - * - topic: Optional topic for new chat room (ignored if room already exists) - * - artistId: Optional UUID of the artist account - * - model: Optional model ID override - * - excludeTools: Optional array of tool names to exclude - * - accountId: Optional accountId override (requires org API key) - * - * Response body: - * - text: The generated text response - * - reasoningText: Optional reasoning text (for models that support it) - * - sources: Array of sources used in generation - * - finishReason: The reason generation finished - * - usage: Token usage information - * - response: Additional response metadata - * - * @param request - The request object - * @returns A JSON response with the generated text or error - */ -export async function POST(request: NextRequest): Promise { - return handleChatGenerate(request); -} diff --git a/app/api/chat/runs/[runId]/route.ts b/app/api/chat/runs/[runId]/route.ts new file mode 100644 index 000000000..725e06593 --- /dev/null +++ b/app/api/chat/runs/[runId]/route.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleChatRunStatus } from "@/lib/chat/runs/handleChatRunStatus"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/chat/runs/{runId} + * + * Point-in-time status of an asynchronous run started via `POST /api/chat/runs` + * (recoupable/chat#1813). Returns `{ runId, status }` — a snapshot ("is it + * done?"), not the generated content. Read the content via the chat (`chatId` + * from the start response): `GET /api/chat/{chatId}/stream`, or the persisted + * messages. + * + * Authentication: x-api-key header required. + * + * @param request - The request object. + * @param ctx - Route context with the `runId` path param. + * @param ctx.params - Promise resolving to the `{ runId }` path params. + * @returns 200 `{ runId, status }`, 401/403 on auth, or 404 if the run is unknown. + */ +export async function GET( + request: NextRequest, + ctx: { params: Promise<{ runId: string }> }, +): Promise { + const { runId } = await ctx.params; + return handleChatRunStatus(request, runId); +} + +export const dynamic = "force-dynamic"; diff --git a/app/api/chat/runs/route.ts b/app/api/chat/runs/route.ts new file mode 100644 index 000000000..0f667665b --- /dev/null +++ b/app/api/chat/runs/route.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleStartChatRun } from "@/lib/chat/runs/handleStartChatRun"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/chat/runs + * + * Start an asynchronous, headless chat-generation run on the durable + * `runAgentWorkflow` (recoupable/chat#1813). Provisions a session + sandbox, + * starts a workflow run, and returns `{ runId, chatId, sessionId }` with **202** + * immediately (plus a `Location` header at the run-status resource) — generation, + * assistant-message persistence, and side effects happen server-side after the + * response. + * + * Authentication: x-api-key header required (account inferred from the key; + * org keys may override via body `accountId`). + * + * Request body: + * - prompt: String prompt (mutually exclusive with messages) + * - messages: Array of UIMessages (mutually exclusive with prompt) + * - artistId: Optional UUID of the artist account + * - model: Optional model ID override (default anthropic/claude-haiku-4.5) + * - topic: Optional session title + * - accountId: Optional accountId override (requires org API key) + * + * Response body (202): `{ runId, chatId, sessionId }`. Read the result later via + * `GET /api/chat/{chatId}/stream` (watch the stream) or the chat's persisted + * messages; poll `GET /api/chat/runs/{runId}` for status (status route lands in + * a follow-up). + * + * @param request - The request object + * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error + */ +export async function POST(request: NextRequest): Promise { + return handleStartChatRun(request); +} + +// Provisioning (repo + session + sandbox) runs before the 202 returns, so give +// the function headroom beyond the default. The workflow itself runs durably +// outside this request. +export const maxDuration = 120; +export const dynamic = "force-dynamic"; diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index c49adfe6f..be1adf5d0 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -6,7 +6,11 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; +import { deleteEphemeralKeyStep } from "@/app/lib/workflows/deleteEphemeralKeyStep"; +vi.mock("@/app/lib/workflows/deleteEphemeralKeyStep", () => ({ + deleteEphemeralKeyStep: vi.fn(), +})); vi.mock("@/app/lib/workflows/runAgentStep", () => ({ runAgentStep: vi.fn(), })); @@ -90,6 +94,37 @@ describe("runAgentWorkflow", () => { expect(clearChatActiveStream).toHaveBeenCalledWith("chat-1", "wrun_from_metadata"); }); + it("deletes the ephemeral key on run end when agentContext.ephemeralKeyId is set (headless)", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + aborted: false, + responseMessage: undefined, + }); + + await runAgentWorkflow({ + ...baseInput, + agentContext: { + sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" }, + ephemeralKeyId: "ephem-key-1", + } as never, + }); + + expect(deleteEphemeralKeyStep).toHaveBeenCalledTimes(1); + expect(deleteEphemeralKeyStep).toHaveBeenCalledWith("ephem-key-1"); + }); + + it("does NOT delete a key for the interactive path (no ephemeralKeyId)", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + aborted: false, + responseMessage: undefined, + }); + + await runAgentWorkflow(baseInput); + + expect(deleteEphemeralKeyStep).not.toHaveBeenCalled(); + }); + it("explicitly closes the chat writable after a successful run so SSE ends promptly", async () => { vi.mocked(runAgentStep).mockResolvedValue({ finishReason: "stop", diff --git a/app/lib/workflows/deleteEphemeralKeyStep.ts b/app/lib/workflows/deleteEphemeralKeyStep.ts new file mode 100644 index 000000000..614323a82 --- /dev/null +++ b/app/lib/workflows/deleteEphemeralKeyStep.ts @@ -0,0 +1,25 @@ +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; + +/** + * Vercel Workflow `"use step"` that deletes the ephemeral, account-scoped + * `recoup_sk_…` key minted for a headless `/api/chat/runs` run + * (recoupable/chat#1813). Called from `runAgentWorkflow`'s `finally` so the + * credential is revoked the moment the run ends — the key's ~15m `expires_at` + * TTL (enforced in `getApiKeyAccountId`) is only the backstop if this is missed. + * + * Defensively swallows its own errors: a cleanup hiccup must not fail the run, + * and the TTL still guarantees the key can't outlive its window. + * + * @param keyId - `account_api_keys.id` of the ephemeral key to delete. + */ +export async function deleteEphemeralKeyStep(keyId: string): Promise { + "use step"; + try { + const { error } = await deleteApiKey(keyId); + if (error) { + console.error(`[deleteEphemeralKeyStep] failed to delete key ${keyId}:`, error); + } + } catch (error) { + console.error(`[deleteEphemeralKeyStep] unhandled error deleting key ${keyId}:`, error); + } +} diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index c6642161a..c0cdec7ff 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -4,6 +4,7 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; +import { deleteEphemeralKeyStep } from "@/app/lib/workflows/deleteEphemeralKeyStep"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; import type { AgentMessageMetadata } from "@/lib/agent/messageMetadata/AgentMessageMetadata"; @@ -151,11 +152,19 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -// Mock all dependencies before importing the module under test -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -vi.mock("@/lib/chat/setupChatRequest", () => ({ - setupChatRequest: vi.fn(), -})); - -vi.mock("@/lib/chat/saveChatCompletion", () => ({ - saveChatCompletion: vi.fn(), -})); - -vi.mock("@/lib/uuid/generateUUID", () => { - const mockFn = vi.fn(() => "auto-generated-room-id"); - return { - generateUUID: mockFn, - default: mockFn, - }; -}); - -vi.mock("@/lib/chat/createNewRoom", () => ({ - createNewRoom: vi.fn(), -})); - -vi.mock("@/lib/supabase/memories/insertMemories", () => ({ - default: vi.fn(), -})); - -vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ - default: vi.fn((msg: unknown) => msg), -})); - -vi.mock("@/lib/chat/setupConversation", () => ({ - setupConversation: vi.fn(), -})); - -const mockValidateAuthContext = vi.mocked(validateAuthContext); -const mockSetupChatRequest = vi.mocked(setupChatRequest); -const mockSaveChatCompletion = vi.mocked(saveChatCompletion); -const mockSetupConversation = vi.mocked(setupConversation); - -// Helper to create a mock agent with .generate() -/** - * - * @param generateResult - */ -function createMockAgent(generateResult: Record) { - return { - generate: vi.fn().mockResolvedValue(generateResult), - stream: vi.fn(), - tools: {}, - }; -} - -// Helper to create mock NextRequest -/** - * - * @param body - * @param headers - */ -function createMockRequest(body: unknown, headers: Record = {}): Request { - return { - json: () => Promise.resolve(body), - headers: { - get: (key: string) => headers[key.toLowerCase()] || null, - has: (key: string) => key.toLowerCase() in headers, - }, - } as unknown as Request; -} - -describe("handleChatGenerate", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Default mock for setupConversation - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-id", - memoryId: "auto-generated-memory-id", - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("validation", () => { - it("returns 400 error when neither messages nor prompt is provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const request = createMockRequest({ roomId: "room-123" }, { "x-api-key": "test-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(400); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 401 error when no auth header is provided", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - const request = createMockRequest({ prompt: "Hello" }, {}); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(401); - }); - }); - - describe("text generation", () => { - it("returns generated text using agent.generate() for valid requests", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Hello! How can I help you?", - reasoningText: undefined, - sources: [], - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { - messages: [], - headers: {}, - body: null, - }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello, world!" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(mockAgent.generate).toHaveBeenCalled(); - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.text).toBe("Hello! How can I help you?"); - expect(json.finishReason).toBe("stop"); - expect(json.usage).toEqual({ promptTokens: 10, completionTokens: 20 }); - }); - - it("uses messages array when provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const messages = [{ role: "user", content: "Hello" }]; - const request = createMockRequest({ messages }, { "x-api-key": "valid-key" }); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - messages, - accountId: "account-123", - }), - ); - }); - - it("passes through optional parameters", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-xyz", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest( - { - prompt: "Hello", - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }), - ); - }); - - it("includes reasoningText when present", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - reasoningText: "Let me think about this...", - sources: [{ url: "https://example.com" }], - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.reasoningText).toBe("Let me think about this..."); - expect(json.sources).toEqual([{ url: "https://example.com" }]); - }); - }); - - describe("error handling", () => { - it("returns 500 error when setupChatRequest fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 500 error when agent.generate() fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = { - generate: vi.fn().mockRejectedValue(new Error("Generation failed")), - stream: vi.fn(), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - }); - - describe("accountId override", () => { - it("allows accountId override", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "target-account-456", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest( - { prompt: "Hello", accountId: "target-account-456" }, - { "x-api-key": "org-api-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "target-account-456", - }), - ); - }); - }); - - describe("message persistence", () => { - it("saves assistant message to database when roomId is provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-abc-123", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Hello! How can I help you?", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-abc-123" }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "Hello! How can I help you?", - roomId: "room-abc-123", - }); - }); - - it("saves message with auto-generated roomId when roomId is not provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-id", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - await handleChatGenerate(request as any); - - // Since roomId is auto-created, saveChatCompletion should be called - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "Response", - roomId: "auto-generated-room-id", - }); - }); - - it("includes roomId in HTTP response when provided by client", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "client-provided-room-id", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "client-provided-room-id" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.roomId).toBe("client-provided-room-id"); - }); - - it("includes auto-generated roomId in HTTP response when not provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-456", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.roomId).toBe("auto-generated-room-456"); - }); - - it("passes correct text to saveChatCompletion", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-xyz", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "This is the assistant response text", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-xyz" }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "This is the assistant response text", - roomId: "room-xyz", - }); - }); - - it("still returns success response even if saveChatCompletion fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-abc", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockRejectedValue(new Error("Database error")); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-abc" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.text).toBe("Response"); - }); - }); -}); diff --git a/lib/chat/buildRunAgentInput.ts b/lib/chat/buildRunAgentInput.ts index 95b0df423..0630bf562 100644 --- a/lib/chat/buildRunAgentInput.ts +++ b/lib/chat/buildRunAgentInput.ts @@ -20,9 +20,14 @@ export type BuildRunAgentInputParams = { /** * Short-lived bearer for in-sandbox recoup-api calls: the user's Privy JWT * (interactive `/api/chat/workflow`) or an ephemeral account key (headless - * `/api/chat/generate`). Omitted when absent so the service key never leaks. + * `/api/chat/runs`). Omitted when absent so the service key never leaks. */ recoupAccessToken?: string; + /** + * Row id of an ephemeral key minted for a headless run, so the workflow can + * delete it on run end (recoupable/chat#1813). Interactive callers omit it. + */ + ephemeralKeyId?: string; }; /** @@ -43,6 +48,7 @@ export function buildRunAgentInput({ workingDirectory, skills, recoupAccessToken, + ephemeralKeyId, }: BuildRunAgentInputParams): RunAgentWorkflowInput { const repoIds = parseGitHubRepoIdentifiers(cloneUrl); const recoupOrgId = cloneUrl ? (extractOrgId(cloneUrl) ?? undefined) : undefined; @@ -61,6 +67,7 @@ export function buildRunAgentInput({ recoupOrgId, skills, ...(recoupAccessToken ? { recoupAccessToken } : {}), + ...(ephemeralKeyId ? { ephemeralKeyId } : {}), }, }; } diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleChatGenerate.ts deleted file mode 100644 index 1f0a4a970..000000000 --- a/lib/chat/handleChatGenerate.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { validateChatRequest } from "./validateChatRequest"; -import { setupChatRequest } from "./setupChatRequest"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { saveChatCompletion } from "./saveChatCompletion"; - -/** - * Handles a non-streaming chat generate request. - * - * This function: - * 1. Validates the request (auth, body schema) - * 2. Sets up the chat configuration (agent, model, tools) - * 3. Generates text using the AI SDK's generateText - * 4. Persists the assistant message to the database (if roomId is provided) - * 5. Returns a JSON response with text, reasoning, sources, etc. - * - * @param request - The incoming NextRequest - * @returns A JSON response or error NextResponse - */ -export async function handleChatGenerate(request: NextRequest): Promise { - const validatedBodyOrError = await validateChatRequest(request); - if (validatedBodyOrError instanceof NextResponse) { - return validatedBodyOrError; - } - const body = validatedBodyOrError; - - try { - const chatConfig = await setupChatRequest(body); - const { agent } = chatConfig; - - const result = await agent.generate(chatConfig); - - // Save assistant message to database - // Note: roomId is always defined after validateChatRequest (auto-created if not provided) - try { - await saveChatCompletion({ - text: result.text, - roomId: body.roomId, - }); - } catch (error) { - // Log error but don't fail the request - message persistence is non-critical - console.error("Failed to persist assistant message:", error); - } - - return NextResponse.json( - { - text: result.text, - roomId: body.roomId, - reasoningText: result.reasoningText, - sources: result.sources, - finishReason: result.finishReason, - usage: result.usage, - response: { - messages: result.response.messages, - headers: result.response.headers, - body: result.response.body, - }, - }, - { - status: 200, - headers: getCorsHeaders(), - }, - ); - } catch (e) { - console.error("/api/chat/generate Global error:", e); - return NextResponse.json( - { - status: "error", - message: e instanceof Error ? e.message : "Unknown error", - }, - { - status: 500, - headers: getCorsHeaders(), - }, - ); - } -} diff --git a/lib/chat/runs/__tests__/handleChatRunStatus.test.ts b/lib/chat/runs/__tests__/handleChatRunStatus.test.ts new file mode 100644 index 000000000..d1f4169e8 --- /dev/null +++ b/lib/chat/runs/__tests__/handleChatRunStatus.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { handleChatRunStatus } from "@/lib/chat/runs/handleChatRunStatus"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getRun } from "workflow/api"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("workflow/api", () => ({ + getRun: vi.fn(), +})); + +const req = () => + new NextRequest("https://x.test/api/chat/runs/wrun_abc", { + headers: { "x-api-key": "recoup_sk_test" }, + }); + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "recoup_sk_test" }; + +describe("handleChatRunStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + }); + + it("returns 200 { runId, status } mapping the workflow status", async () => { + vi.mocked(getRun).mockReturnValue({ status: Promise.resolve("running") } as never); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ runId: "wrun_abc", status: "running" }); + }); + + it("normalizes pending → running and completed/failed/cancelled through", async () => { + for (const [raw, want] of [ + ["pending", "running"], + ["completed", "completed"], + ["failed", "failed"], + ["cancelled", "cancelled"], + ] as const) { + vi.mocked(getRun).mockReturnValue({ status: Promise.resolve(raw) } as never); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect((await res.json()).status).toBe(want); + } + }); + + it("returns the auth error short-circuit", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect(res.status).toBe(401); + expect(getRun).not.toHaveBeenCalled(); + }); + + it("404s when the run is not found (getRun throws)", async () => { + vi.mocked(getRun).mockImplementation(() => { + throw new Error("run not found"); + }); + const res = await handleChatRunStatus(req(), "wrun_missing"); + expect(res.status).toBe(404); + }); +}); diff --git a/lib/chat/runs/__tests__/handleStartChatRun.test.ts b/lib/chat/runs/__tests__/handleStartChatRun.test.ts new file mode 100644 index 000000000..3f1c8555b --- /dev/null +++ b/lib/chat/runs/__tests__/handleStartChatRun.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { handleStartChatRun } from "@/lib/chat/runs/handleStartChatRun"; +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { provisionRunSession } from "@/lib/chat/runs/provisionRunSession"; +import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { start } from "workflow/api"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("@/lib/chat/runs/validateChatRunRequest", () => ({ + validateChatRunRequest: vi.fn(), +})); +vi.mock("@/lib/chat/runs/provisionRunSession", () => ({ + provisionRunSession: vi.fn(), +})); +vi.mock("@/lib/keys/mintEphemeralAccountKey", () => ({ + mintEphemeralAccountKey: vi.fn(), +})); +vi.mock("@/lib/supabase/account_api_keys/deleteApiKey", () => ({ + deleteApiKey: vi.fn(async () => ({ error: null })), +})); +vi.mock("@/lib/chat/buildRunAgentInput", () => ({ + buildRunAgentInput: vi.fn(x => ({ built: true, ...x })), +})); +vi.mock("workflow/api", () => ({ + start: vi.fn(), +})); +vi.mock("@/app/lib/workflows/runAgentWorkflow", () => ({ runAgentWorkflow: vi.fn() })); + +const req = () => + new NextRequest("https://x.test/api/chat/generate", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "recoup_sk_test" }, + body: JSON.stringify({ prompt: "go" }), + }); + +const validated = { + accountId: "acc-1", + orgId: null, + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "go" }] }], + artistId: undefined, + modelId: "anthropic/claude-haiku-4.5", +}; + +const provisioned = { + session: { + id: "sess-1", + clone_url: "https://github.com/recoupable/acc-1", + title: "Scheduled generation", + }, + chat: { id: "chat-1" }, + sandboxState: { type: "vercel", sandboxName: "session-sess-1" }, + workingDirectory: "/vercel/sandbox", + skills: [], +}; + +describe("handleStartChatRun", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateChatRunRequest).mockResolvedValue(validated as never); + vi.mocked(provisionRunSession).mockResolvedValue(provisioned as never); + vi.mocked(mintEphemeralAccountKey).mockResolvedValue({ + rawKey: "recoup_sk_raw", + keyId: "key-1", + }); + vi.mocked(start).mockResolvedValue({ runId: "wrun_abc" } as never); + }); + + it("provisions, mints, starts the workflow, and returns 202 { runId }", async () => { + const res = await handleStartChatRun(req()); + expect(res.status).toBe(202); + expect(res.headers.get("Location")).toBe("/api/chat/runs/wrun_abc"); + expect(await res.json()).toEqual({ + runId: "wrun_abc", + chatId: "chat-1", + sessionId: "sess-1", + }); + + expect(provisionRunSession).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "acc-1", title: "Scheduled generation" }), + ); + // the minted key is injected as recoupAccessToken AND threaded as ephemeralKeyId + expect(buildRunAgentInput).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: "chat-1", + sessionId: "sess-1", + recoupAccessToken: "recoup_sk_raw", + ephemeralKeyId: "key-1", + }), + ); + expect(start).toHaveBeenCalledOnce(); + // key is NOT deleted here — the workflow's finally owns that on run end + expect(deleteApiKey).not.toHaveBeenCalled(); + }); + + it("returns the validation error short-circuit", async () => { + vi.mocked(validateChatRunRequest).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const res = await handleStartChatRun(req()); + expect(res.status).toBe(401); + expect(provisionRunSession).not.toHaveBeenCalled(); + }); + + it("revokes the minted key and 500s when start() fails", async () => { + vi.mocked(start).mockRejectedValue(new Error("workflow start boom")); + const res = await handleStartChatRun(req()); + expect(res.status).toBe(500); + expect(deleteApiKey).toHaveBeenCalledWith("key-1"); + }); + + it("does not mint or delete a key when provisioning fails", async () => { + vi.mocked(provisionRunSession).mockRejectedValue(new Error("repo boom")); + const res = await handleStartChatRun(req()); + expect(res.status).toBe(500); + expect(mintEphemeralAccountKey).not.toHaveBeenCalled(); + expect(deleteApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/chat/runs/__tests__/normalizeRunStatus.test.ts b/lib/chat/runs/__tests__/normalizeRunStatus.test.ts new file mode 100644 index 000000000..1b6ccd845 --- /dev/null +++ b/lib/chat/runs/__tests__/normalizeRunStatus.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRunStatus } from "@/lib/chat/runs/normalizeRunStatus"; + +describe("normalizeRunStatus", () => { + it("maps known states to the documented enum", () => { + expect(normalizeRunStatus("queued")).toBe("queued"); + expect(normalizeRunStatus("running")).toBe("running"); + expect(normalizeRunStatus("pending")).toBe("running"); + expect(normalizeRunStatus("completed")).toBe("completed"); + expect(normalizeRunStatus("succeeded")).toBe("completed"); + expect(normalizeRunStatus("failed")).toBe("failed"); + expect(normalizeRunStatus("error")).toBe("failed"); + expect(normalizeRunStatus("cancelled")).toBe("cancelled"); + expect(normalizeRunStatus("canceled")).toBe("cancelled"); + }); + + it("is case-insensitive", () => { + expect(normalizeRunStatus("COMPLETED")).toBe("completed"); + expect(normalizeRunStatus("Running")).toBe("running"); + }); + + it("defaults unknown strings to running (no invented terminal state)", () => { + expect(normalizeRunStatus("something-new")).toBe("running"); + }); +}); diff --git a/lib/chat/runs/__tests__/validateChatRunRequest.test.ts b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts new file mode 100644 index 000000000..aae8f0903 --- /dev/null +++ b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +function req(body: unknown): NextRequest { + return new NextRequest("https://x.test/api/chat/runs", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "recoup_sk_test" }, + body: JSON.stringify(body), + }); +} + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "recoup_sk_test" }; + +describe("validateChatRunRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + }); + + it("converts a prompt into a single user UIMessage", async () => { + const result = await validateChatRunRequest(req({ prompt: "weekly report please" })); + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect(result.accountId).toBe("acc-1"); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("user"); + expect(JSON.stringify(result.messages[0].parts)).toContain("weekly report please"); + }); + + it("passes messages through and applies the model override + default", async () => { + const withModel = await validateChatRunRequest( + req({ + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], + model: "anthropic/claude-opus-4-8", + }), + ); + if (withModel instanceof NextResponse) throw new Error("unexpected error"); + expect(withModel.modelId).toBe("anthropic/claude-opus-4-8"); + + const noModel = await validateChatRunRequest(req({ prompt: "hi" })); + if (noModel instanceof NextResponse) throw new Error("unexpected error"); + expect(noModel.modelId).toBe("anthropic/claude-haiku-4.5"); + }); + + it("rejects when neither prompt nor messages is provided (400)", async () => { + const result = await validateChatRunRequest(req({ model: "anthropic/claude-haiku-4.5" })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(400); + }); + + it("rejects a whitespace-only prompt (400)", async () => { + const result = await validateChatRunRequest(req({ prompt: " \n\t " })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(400); + }); + + it("returns the auth error response when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const result = await validateChatRunRequest(req({ prompt: "hi" })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(401); + }); + + it("forwards body accountId override to validateAuthContext", async () => { + await validateChatRunRequest(req({ prompt: "hi", accountId: "member-acc" })); + expect(validateAuthContext).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ accountId: "member-acc" }), + ); + }); +}); diff --git a/lib/chat/runs/handleChatRunStatus.ts b/lib/chat/runs/handleChatRunStatus.ts new file mode 100644 index 000000000..a26fcbd8f --- /dev/null +++ b/lib/chat/runs/handleChatRunStatus.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getRun } from "workflow/api"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { normalizeRunStatus } from "@/lib/chat/runs/normalizeRunStatus"; + +/** + * Handles `GET /api/chat/runs/{runId}` — a point-in-time status snapshot for an + * asynchronous run started via `POST /api/chat/runs` (recoupable/chat#1813). + * Wraps the durable workflow's `getRun(runId).status`; returns `{ runId, status }`. + * Not the generated content — read that via the chat (`chatId` from the 202 start + * response): `GET /api/chat/{chatId}/stream` or the persisted messages. + * + * @param request - The incoming request (x-api-key auth). + * @param runId - The durable workflow run id from the path. + * @returns 200 `{ runId, status }`, 401/403 on auth, or 404 if the run is unknown. + */ +export async function handleChatRunStatus(request: NextRequest, runId: string): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) return auth; + + let rawStatus: string; + try { + rawStatus = await getRun(runId).status; + } catch (error) { + console.error(`[handleChatRunStatus] run not found ${runId}:`, error); + return errorResponse("Run not found", 404); + } + + return NextResponse.json( + { runId, status: normalizeRunStatus(rawStatus) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/chat/runs/handleStartChatRun.ts b/lib/chat/runs/handleStartChatRun.ts new file mode 100644 index 000000000..2db6a305a --- /dev/null +++ b/lib/chat/runs/handleStartChatRun.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { start } from "workflow/api"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { provisionRunSession } from "@/lib/chat/runs/provisionRunSession"; +import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; + +/** Default title for the session a headless run provisions (no caller-supplied title). */ +const DEFAULT_RUN_SESSION_TITLE = "Scheduled generation"; + +/** + * Handles `POST /api/chat/runs` — the headless, asynchronous counterpart of + * interactive `/api/chat`. Runs on the durable `runAgentWorkflow` + * (recoupable/chat#1813): it provisions a session + active sandbox, mints a + * short-lived account-scoped `recoup_sk_…` key for in-sandbox `recoup-api` + * calls, builds the shared workflow input via `buildRunAgentInput`, and + * `start()`s the run — returning `{ runId, chatId, sessionId }` with **202** + * (plus a `Location` header pointing at the run-status resource) immediately. + * + * Generation, assistant-message persistence, the credit charge, and the + * ephemeral-key revocation all happen server-side inside the workflow after + * this response. The legacy synchronous `ToolLoopAgent` path is gone. + * + * The minted key is injected as the agent's `recoupAccessToken` (so the service + * key never enters model-issued bash) and threaded as `ephemeralKeyId` so the + * workflow deletes it on run end; its ~15m TTL is the backstop. If the run + * fails to start, we revoke the key here since the workflow never ran. + * + * @param request - The incoming request (x-api-key auth). + * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error. + */ +export async function handleStartChatRun(request: NextRequest): Promise { + const validated = await validateChatRunRequest(request); + if (validated instanceof NextResponse) return validated; + + const { accountId, messages, artistId, modelId } = validated; + + let ephemeralKeyId: string | undefined; + try { + const provisioned = await provisionRunSession({ + accountId, + title: DEFAULT_RUN_SESSION_TITLE, + artistId, + }); + + const { rawKey, keyId } = await mintEphemeralAccountKey(accountId); + ephemeralKeyId = keyId; + + const run = await start(runAgentWorkflow, [ + buildRunAgentInput({ + messages, + chatId: provisioned.chat.id, + sessionId: provisioned.session.id, + accountId, + modelId, + sessionTitle: provisioned.session.title ?? undefined, + cloneUrl: provisioned.session.clone_url, + sandboxState: provisioned.sandboxState, + workingDirectory: provisioned.workingDirectory, + skills: provisioned.skills, + recoupAccessToken: rawKey, + ephemeralKeyId: keyId, + }), + ]); + + // Return the run handle plus the persisted-output identifiers so the caller + // can read the result later (the workflow runId alone can't be resolved back + // to the chat): GET /api/chat/{chatId}/stream resumes the stream, and the + // assistant messages persist under chatId. The Location header points at the + // run-status resource. Mirrors the async-job shape of POST /api/content/create. + return NextResponse.json( + { runId: run.runId, chatId: provisioned.chat.id, sessionId: provisioned.session.id }, + { + status: 202, + headers: { ...getCorsHeaders(), Location: `/api/chat/runs/${run.runId}` }, + }, + ); + } catch (error) { + // The workflow's `finally` revokes the key on run end — but if we never got + // there (provisioning ok, then mint ok, then start threw), the key would + // linger until its TTL. Revoke it now. If mint itself threw, there's no key. + if (ephemeralKeyId) { + try { + await deleteApiKey(ephemeralKeyId); + } catch (cleanupError) { + console.error("[handleStartChatRun] failed to revoke ephemeral key:", cleanupError); + } + } + console.error("[handleStartChatRun] failed to start generation run:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/chat/runs/normalizeRunStatus.ts b/lib/chat/runs/normalizeRunStatus.ts new file mode 100644 index 000000000..f6411c8fb --- /dev/null +++ b/lib/chat/runs/normalizeRunStatus.ts @@ -0,0 +1,34 @@ +export type ChatRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; + +/** + * Normalize a Vercel Workflow run state to the `ChatRunStatusResponse` enum + * documented for `GET /api/chat/runs/{runId}` (recoupable/chat#1813). `pending` + * is treated as running (matching the codebase's `RUNNING_STATUSES`). An unknown + * string is surfaced as `running` rather than inventing a terminal state. + * + * @param raw - The raw `getRun(runId).status` string. + * @returns The normalized lifecycle state. + */ +export function normalizeRunStatus(raw: string): ChatRunStatus { + switch (raw.toLowerCase()) { + case "queued": + return "queued"; + case "running": + case "pending": + return "running"; + case "completed": + case "complete": + case "succeeded": + case "success": + return "completed"; + case "failed": + case "errored": + case "error": + return "failed"; + case "cancelled": + case "canceled": + return "cancelled"; + default: + return "running"; + } +} diff --git a/lib/chat/runs/provisionRunSession.ts b/lib/chat/runs/provisionRunSession.ts new file mode 100644 index 000000000..f1ed3bd95 --- /dev/null +++ b/lib/chat/runs/provisionRunSession.ts @@ -0,0 +1,98 @@ +import ms from "ms"; +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { discoverSkills } from "@/lib/skills/discoverSkills"; +import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; +import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; +import type { Json, Tables } from "@/types/database.types"; +import type { VercelState } from "@/lib/sandbox/vercel/state"; +import type { SkillMetadata } from "@/lib/skills/skillTypes"; + +const SANDBOX_TIMEOUT_MS = ms("30m"); + +export type ProvisionedRunSession = { + session: Tables<"sessions">; + chat: Tables<"chats">; + sandboxState: VercelState; + workingDirectory: string; + skills: SkillMetadata[]; +}; + +/** + * Headlessly provision a session + chat with an ACTIVE sandbox for a + * `POST /api/chat/runs` run (recoupable/chat#1813) — composing the same shared + * building blocks the interactive path uses: `createSessionWithInitialChat` + * (`POST /api/sessions`) + `connectSandbox` / `markSessionSandboxActive` + * (`POST /api/sandbox`). Server-side since there is no client session. + * + * @throws if repo/session/chat/sandbox provisioning fails — the caller maps it + * to a 5xx and revokes any minted ephemeral key. + */ +export async function provisionRunSession({ + accountId, + title, + artistId, +}: { + accountId: string; + title: string; + artistId?: string; +}): Promise { + const created = await createSessionWithInitialChat({ + accountId, + title, + chatTitle: "Scheduled generation", + artistId, + }); + if (created.ok === false) { + throw new Error( + created.reason === "repo" + ? "Failed to provision workspace repository" + : "Failed to create session", + ); + } + const { session, chat } = created; + + const sandbox = await connectSandbox({ + state: { + type: "vercel", + sandboxName: getSessionSandboxName(session.id), + source: { repo: session.clone_url, prebuilt: false }, + }, + options: { + timeout: SANDBOX_TIMEOUT_MS, + ports: [3000], + githubToken: getServiceGithubToken(), + gitUser: await resolveGitUser(accountId), + persistent: true, + resume: true, + createIfMissing: true, + }, + }); + + const updated = await markSessionSandboxActive(session, sandbox.getState() as Json); + if (!updated) throw new Error("Failed to activate session sandbox"); + + // Best-effort skill + working-directory discovery from the live handle — + // a failure falls back to defaults so the run can still start (tools surface + // the underlying issue when they reconnect). Mirrors handleChatWorkflowStream. + let workingDirectory = DEFAULT_WORKING_DIRECTORY; + let skills: SkillMetadata[] = []; + try { + workingDirectory = sandbox.workingDirectory; + skills = await discoverSkills(sandbox, await getSandboxSkillDirectories(sandbox)); + } catch (error) { + console.error("[provisionRunSession] skill discovery failed; using defaults:", error); + } + + return { + session: updated, + chat, + sandboxState: updated.sandbox_state as VercelState, + workingDirectory, + skills, + }; +} diff --git a/lib/chat/runs/validateChatRunRequest.ts b/lib/chat/runs/validateChatRunRequest.ts new file mode 100644 index 000000000..154f47e6c --- /dev/null +++ b/lib/chat/runs/validateChatRunRequest.ts @@ -0,0 +1,88 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import type { UIMessage } from "ai"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validationErrorResponse } from "@/lib/zod/validationErrorResponse"; +import { generateUUID } from "@/lib/uuid/generateUUID"; + +/** Default model for headless generation when the caller omits `model`. */ +export const DEFAULT_RUN_MODEL_ID = "anthropic/claude-haiku-4.5"; + +/** + * Body schema for `POST /api/chat/runs` (the durable-workflow re-point, + * recoupable/chat#1813). Exactly one of `prompt` / `messages` must be present. + * Mirrors `/api/chat`: no session-title / room / tool-exclusion params — this + * path mints its own session + chat (with a default title) and runs native + * sandbox tools. The legacy `topic` / `roomId` / `excludeTools` fields are gone. + */ +export const chatRunBodySchema = z.object({ + prompt: z.string().optional(), + messages: z.array(z.any()).optional(), + artistId: z.string().uuid("artistId must be a valid UUID").optional(), + accountId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), +}); + +export type ChatRunRequest = { + accountId: string; + orgId: string | null; + messages: UIMessage[]; + artistId?: string; + modelId: string; +}; + +/** + * Validates a `POST /api/chat/runs` request end-to-end: parses + validates + * the body, runs auth via `validateAuthContext` (x-api-key, with org-key + * account override), and normalizes `prompt`/`messages` into a `UIMessage[]`. + * + * @param request - The incoming NextRequest. + * @returns A NextResponse error short-circuit (400/401/403) or the validated, + * auth-augmented request ready to provision + start a workflow run. + */ +export async function validateChatRunRequest( + request: NextRequest, +): Promise { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResponse("Invalid JSON body", 400); + } + + const parsed = chatRunBodySchema.safeParse(rawBody); + if (!parsed.success) { + const firstError = parsed.error.issues[0]; + return validationErrorResponse(firstError.message, firstError.path); + } + + const { prompt, messages, artistId, accountId, organizationId, model } = parsed.data; + + const trimmedPrompt = typeof prompt === "string" ? prompt.trim() : ""; + const hasPrompt = trimmedPrompt.length > 0; + const hasMessages = Array.isArray(messages) && messages.length > 0; + if (hasPrompt === hasMessages) { + return errorResponse("Exactly one of prompt or messages must be provided", 400); + } + + const auth = await validateAuthContext(request, { + accountId, + organizationId: organizationId ?? null, + }); + if (auth instanceof NextResponse) return auth; + + const uiMessages: UIMessage[] = hasPrompt + ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: trimmedPrompt }] }] + : (messages as UIMessage[]); + + return { + accountId: auth.accountId, + orgId: auth.orgId, + messages: uiMessages, + artistId, + modelId: model ?? DEFAULT_RUN_MODEL_ID, + }; +} diff --git a/lib/keys/__tests__/mintEphemeralAccountKey.test.ts b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts new file mode 100644 index 000000000..1c7ba3614 --- /dev/null +++ b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + mintEphemeralAccountKey, + DEFAULT_EPHEMERAL_KEY_TTL_MS, +} from "@/lib/keys/mintEphemeralAccountKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; + +vi.mock("@/lib/keys/generateApiKey", () => ({ + generateApiKey: vi.fn(() => "recoup_sk_RAW"), +})); +vi.mock("@/lib/keys/hashApiKey", () => ({ + hashApiKey: vi.fn(() => "hashed"), +})); +vi.mock("@/lib/supabase/account_api_keys/insertApiKey", () => ({ + insertApiKey: vi.fn(), +})); +vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "secret" })); + +describe("mintEphemeralAccountKey", () => { + beforeEach(() => vi.clearAllMocks()); + + it("mints an account-scoped recoup_sk_ key with a ~15m expiry and returns rawKey + keyId", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: { id: "key-1" }, error: null } as never); + + const before = Date.now(); + const result = await mintEphemeralAccountKey("acc-1"); + const after = Date.now(); + + expect(result).toEqual({ rawKey: "recoup_sk_RAW", keyId: "key-1" }); + + const arg = vi.mocked(insertApiKey).mock.calls[0][0]; + expect(arg.account).toBe("acc-1"); + expect(arg.key_hash).toBe("hashed"); + const expMs = new Date(arg.expires_at as string).getTime(); + expect(expMs).toBeGreaterThanOrEqual(before + DEFAULT_EPHEMERAL_KEY_TTL_MS); + expect(expMs).toBeLessThanOrEqual(after + DEFAULT_EPHEMERAL_KEY_TTL_MS); + }); + + it("throws when the insert fails", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: null, error: { message: "boom" } } as never); + await expect(mintEphemeralAccountKey("acc-1")).rejects.toThrow(/mint/i); + }); +}); diff --git a/lib/keys/mintEphemeralAccountKey.ts b/lib/keys/mintEphemeralAccountKey.ts new file mode 100644 index 000000000..8b138f279 --- /dev/null +++ b/lib/keys/mintEphemeralAccountKey.ts @@ -0,0 +1,45 @@ +import { generateApiKey } from "@/lib/keys/generateApiKey"; +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; +import { PRIVY_PROJECT_SECRET } from "@/lib/const"; + +/** Default lifetime for an ephemeral key: 15 minutes. */ +export const DEFAULT_EPHEMERAL_KEY_TTL_MS = 15 * 60 * 1000; + +export type EphemeralAccountKey = { rawKey: string; keyId: string }; + +/** + * Mint a short-lived, account-scoped `recoup_sk_` api key for a headless run + * (recoupable/chat#1813). Returns the raw key — to inject as `$RECOUP_API_KEY` + * into the sandbox — and the row id, so the caller can delete it on run end. + * The key also auto-expires via `account_api_keys.expires_at` (defense in depth + * if the delete is missed; enforced in `getApiKeyAccountId`). The long-lived + * service key never enters the sandbox. + */ +export async function mintEphemeralAccountKey( + accountId: string, + { + ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, + name = "ephemeral:chat-generate", + }: { + ttlMs?: number; + name?: string; + } = {}, +): Promise { + const rawKey = generateApiKey("recoup_sk"); + const keyHash = hashApiKey(rawKey, PRIVY_PROJECT_SECRET); + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + + const { data, error } = await insertApiKey({ + name, + account: accountId, + key_hash: keyHash, + expires_at: expiresAt, + }); + + if (error || !data) { + throw new Error(`Failed to mint ephemeral api key: ${error?.message ?? "no row returned"}`); + } + + return { rawKey, keyId: data.id }; +} diff --git a/lib/sandbox/__tests__/markSessionSandboxActive.test.ts b/lib/sandbox/__tests__/markSessionSandboxActive.test.ts new file mode 100644 index 000000000..99621fa25 --- /dev/null +++ b/lib/sandbox/__tests__/markSessionSandboxActive.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; + +vi.mock("@/lib/sandbox/buildActiveLifecycleUpdate", () => ({ + buildActiveLifecycleUpdate: vi.fn(() => ({ + lifecycle_state: "active", + sandbox_expires_at: "T+30m", + })), +})); +vi.mock("@/lib/supabase/sessions/updateSession", () => ({ updateSession: vi.fn() })); + +describe("markSessionSandboxActive", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates the session with the state, bumped version, active lifecycle, and cleared snapshot", async () => { + vi.mocked(updateSession).mockResolvedValue({ id: "sess-1" } as never); + const sessionRow = { id: "sess-1", lifecycle_version: 4 } as never; + const state = { type: "vercel", sandboxName: "session-sess-1" } as never; + + const out = await markSessionSandboxActive(sessionRow, state); + + expect(buildActiveLifecycleUpdate).toHaveBeenCalledWith(state); + expect(updateSession).toHaveBeenCalledWith("sess-1", { + sandbox_state: state, + lifecycle_version: 5, + lifecycle_state: "active", + sandbox_expires_at: "T+30m", + snapshot_url: null, + snapshot_created_at: null, + }); + expect(out).toEqual({ id: "sess-1" }); + }); +}); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index a4f5237b5..3f2d435c4 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse, after } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; -import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; import { connectSandbox } from "@/lib/sandbox/factory"; import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; @@ -12,7 +12,6 @@ import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshot import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow"; import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; -import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; import type { Json, Tables } from "@/types/database.types"; @@ -124,21 +123,12 @@ export async function createSandboxHandler(request: NextRequest): Promise, + sandboxState: Json, +): Promise | null> { + return updateSession(sessionRow.id, { + sandbox_state: sandboxState, + lifecycle_version: sessionRow.lifecycle_version + 1, + ...buildActiveLifecycleUpdate(sandboxState), + snapshot_url: null, + snapshot_created_at: null, + }); +} diff --git a/lib/sessions/__tests__/createSessionWithInitialChat.test.ts b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts new file mode 100644 index 000000000..f30286ffd --- /dev/null +++ b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; + +vi.mock("@/lib/uuid/generateUUID", () => ({ generateUUID: vi.fn(() => "chat-uuid") })); +vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ ensurePersonalRepo: vi.fn() })); +vi.mock("@/lib/sessions/buildSessionInsertRow", () => ({ + buildSessionInsertRow: vi.fn(x => ({ row: true, ...x })), +})); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); + +const args = { accountId: "acc-1", title: "T", chatTitle: "New chat", artistId: "art-1" }; + +describe("createSessionWithInitialChat", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ensurePersonalRepo).mockResolvedValue("https://github.com/recoupable/acc-1"); + vi.mocked(insertSession).mockResolvedValue({ id: "sess-1" } as never); + vi.mocked(insertChat).mockResolvedValue({ id: "chat-1" } as never); + vi.mocked(deleteSessionById).mockResolvedValue(true as never); + }); + + it("returns { ok, session, chat } on success and builds the row with the resolved clone url", async () => { + const r = await createSessionWithInitialChat(args); + expect(r).toEqual({ ok: true, session: { id: "sess-1" }, chat: { id: "chat-1" } }); + expect(buildSessionInsertRow).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc-1", + cloneUrl: "https://github.com/recoupable/acc-1", + }), + ); + }); + + it("uses workspaceAccountId for the repo when provided", async () => { + await createSessionWithInitialChat({ ...args, workspaceAccountId: "org-9" }); + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: "org-9" }); + }); + + it("returns reason 'repo' when the workspace repo can't be provisioned", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(null); + expect(await createSessionWithInitialChat(args)).toEqual({ ok: false, reason: "repo" }); + expect(insertSession).not.toHaveBeenCalled(); + }); + + it("returns reason 'insert' when the session insert fails", async () => { + vi.mocked(insertSession).mockResolvedValue(null as never); + expect(await createSessionWithInitialChat(args)).toEqual({ ok: false, reason: "insert" }); + }); + + it("rolls back the session and returns 'insert' when the chat insert fails", async () => { + vi.mocked(insertChat).mockResolvedValue(null as never); + const r = await createSessionWithInitialChat(args); + expect(r).toEqual({ ok: false, reason: "insert" }); + expect(deleteSessionById).toHaveBeenCalledWith("sess-1"); + }); +}); diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index e48020537..9667695cd 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -1,14 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; -import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; -import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { + createSessionWithInitialChat, + type CreateSessionWithChatResult, +} from "@/lib/sessions/createSessionWithInitialChat"; import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; -import { insertSession } from "@/lib/supabase/sessions/insertSession"; -import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; -import { insertChat } from "@/lib/supabase/chats/insertChat"; import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; import { toChatResponse } from "@/lib/sessions/toChatResponse"; @@ -18,11 +16,12 @@ const INITIAL_CHAT_TITLE = "New chat"; * Handles `POST /api/sessions`. * * Authenticates, validates the request, resolves a final session - * title (provided > random city fallback), ensures the workspace repo - * exists at `recoupable/`, then creates - * a session row and an initial chat row. If the chat insert fails - * after the session row is persisted, the session is rolled back so - * callers never observe an orphaned session. + * title (provided > random city fallback), then provisions the workspace + * repo + session + initial chat via the shared + * `createSessionWithInitialChat` (also used by the headless + * `POST /api/chat/runs` path). If the chat insert fails after the session + * is persisted, the session is rolled back so callers never observe an + * orphaned session. * * The clone URL is derived server-side — callers never construct * GitHub URLs. Personal sessions (no `organizationId` in body) use @@ -44,47 +43,26 @@ export async function createSessionHandler(request: NextRequest): Promise; chat: Tables<"chats"> } + | { ok: false; reason: "repo" | "insert" }; + +/** + * Shared core for provisioning a session + its initial chat — used by both the + * interactive `POST /api/sessions` (`createSessionHandler`) and the headless + * `POST /api/chat/runs` path (`provisionRunSession`), so the two stay in + * lockstep (recoupable/chat#1813). + * + * Ensures the workspace repo exists (`recoupable/`), inserts + * the session row, then the initial chat row. If the chat insert fails after the + * session is persisted, the session is rolled back so callers never observe an + * orphaned session. + * + * The clone URL is derived server-side. `workspaceAccountId` (org id for org + * sessions) defaults to `accountId` for personal sessions. + * + * @returns `{ ok: true, session, chat }`, or `{ ok: false, reason }` where + * `"repo"` = workspace-repo provisioning failed and `"insert"` = a session/chat + * insert failed (session already rolled back). Callers map these to their own + * error envelope (the route returns 502/500; the headless path throws). + */ +export async function createSessionWithInitialChat({ + accountId, + workspaceAccountId, + title, + chatTitle, + artistId, +}: { + accountId: string; + workspaceAccountId?: string; + title: string; + chatTitle: string; + artistId?: string; +}): Promise { + const cloneUrl = await ensurePersonalRepo({ accountId: workspaceAccountId ?? accountId }); + if (!cloneUrl) return { ok: false, reason: "repo" }; + + const session = await insertSession( + buildSessionInsertRow({ accountId, title, cloneUrl, artistId }), + ); + if (!session) return { ok: false, reason: "insert" }; + + const chat = await insertChat({ id: generateUUID(), session_id: session.id, title: chatTitle }); + if (!chat) { + const rolledBack = await deleteSessionById(session.id); + if (!rolledBack) { + console.error( + "[createSessionWithInitialChat] chat insert failed and session rollback failed — orphaned session:", + session.id, + ); + } + return { ok: false, reason: "insert" }; + } + + return { ok: true, session, chat }; +} diff --git a/lib/supabase/account_api_keys/insertApiKey.ts b/lib/supabase/account_api_keys/insertApiKey.ts index 3904b2f46..3a058dede 100644 --- a/lib/supabase/account_api_keys/insertApiKey.ts +++ b/lib/supabase/account_api_keys/insertApiKey.ts @@ -8,12 +8,14 @@ import type { Database } from "@/types/database.types"; * @param input.name - The input object containing the name, account, and key_hash * @param input.account - The account ID * @param input.key_hash - The hash of the API key + * @param input.expires_at - Optional ISO expiry for ephemeral keys (NULL = never) * @returns The inserted API key */ export async function insertApiKey({ name, account, key_hash, + expires_at, }: Database["public"]["Tables"]["account_api_keys"]["Insert"]) { const { data, error } = await supabase .from("account_api_keys") @@ -21,6 +23,7 @@ export async function insertApiKey({ name, account, key_hash, + expires_at, }) .select() .single(); From 4c09cf065d50fc7ffabbe4e2a9c93b05468d4526 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Wed, 24 Jun 2026 17:04:39 -0500 Subject: [PATCH 16/27] =?UTF-8?q?Retire=20OpenClaw=20prompt=5Fsandbox=20?= =?UTF-8?q?=E2=86=92=20run-sandbox-command=20bridge=20(chat#1813)=20(#705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(sandbox): retire OpenClaw prompt_sandbox → run-sandbox-command bridge (chat#1813) Async agent work now runs on the durable runAgentWorkflow via POST /api/chat/generate, so the OpenClaw offload bridge is removed: - Delete lib/trigger/triggerPromptSandbox.ts (the only caller of tasks.trigger("run-sandbox-command")). - Delete the prompt_sandbox MCP tool (registerPromptSandboxTool) + its registration (lib/mcp/tools/sandbox/index.ts) and drop it from registerAllTools. - Simplify processCreateSandbox to bare sandbox creation (no prompt, no trigger); drop `prompt` from validateSandboxBody. POST /api/sandboxes now only provisions a sandbox. - Update JSDoc on the route + handler; prune prompt-mode tests. No api code calls run-sandbox-command anymore (grep clean). The shared OpenClaw helpers in the tasks repo stay until their other consumers are migrated (issue Phase 2). Stale prompt_sandbox references in the dead legacy generate stack (SYSTEM_PROMPT, getGeneralAgent, getMcpTools, setupToolsForRequest) are left for a follow-up cleanup PR. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(sandbox): /api/chat/generate → /api/chat/runs in retire-bridge comments The endpoint was renamed in api#704 (now on test/prod); update the JSDoc refs added by this PR to match. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(prompt): remove prompt_sandbox from SYSTEM_PROMPT + create_knowledge_base Retiring the prompt_sandbox MCP tool (this PR) affects LIVE agents, not dead code: the legacy getGeneralAgent stack is still used by Slack chat (handleSlackChatMessage → setupChatRequest) and the inbound email responder (respondToInboundEmail → generateEmailResponse). Both run on SYSTEM_PROMPT and the MCP toolset, so removing the tool while the prompt instructs models to use it would tell live agents to call a tool that no longer exists. - SYSTEM_PROMPT: drop the entire "Sandbox-First Approach" section (it centered on prompt_sandbox as the "primary tool" + release-management-via-sandbox). - create_knowledge_base tool: drop the "(use prompt_sandbox for those)" pointer. - Update both tests to guard that neither references the retired tool. Behavior note: the Slack + email agents lose the prompt_sandbox (OpenClaw) sandbox tool — acceptable since OpenClaw is the failing component this issue removes. Those agents still run on the legacy getGeneralAgent stack (not runAgentWorkflow); migrating them is out of scope (chat#1813). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/sandboxes/route.ts | 18 +- lib/chat/__tests__/const.test.ts | 22 +- lib/chat/const.ts | 16 -- .../registerCreateKnowledgeBaseTool.test.ts | 4 +- .../files/registerCreateKnowledgeBaseTool.ts | 2 +- lib/mcp/tools/index.ts | 2 - .../registerPromptSandboxTool.test.ts | 205 ------------------ lib/mcp/tools/sandbox/index.ts | 11 - .../sandbox/registerPromptSandboxTool.ts | 67 ------ .../createSandboxPostHandler.test.ts | 27 +-- .../__tests__/processCreateSandbox.test.ts | 97 --------- .../__tests__/validateSandboxBody.test.ts | 57 +---- lib/sandbox/createSandboxPostHandler.ts | 7 +- lib/sandbox/processCreateSandbox.ts | 44 ++-- lib/sandbox/validateSandboxBody.ts | 1 - lib/trigger/triggerPromptSandbox.ts | 23 -- 16 files changed, 44 insertions(+), 559 deletions(-) delete mode 100644 lib/mcp/tools/sandbox/__tests__/registerPromptSandboxTool.test.ts delete mode 100644 lib/mcp/tools/sandbox/index.ts delete mode 100644 lib/mcp/tools/sandbox/registerPromptSandboxTool.ts delete mode 100644 lib/sandbox/__tests__/processCreateSandbox.test.ts delete mode 100644 lib/trigger/triggerPromptSandbox.ts diff --git a/app/api/sandboxes/route.ts b/app/api/sandboxes/route.ts index 7b3152d1d..a13c21e93 100644 --- a/app/api/sandboxes/route.ts +++ b/app/api/sandboxes/route.ts @@ -21,22 +21,22 @@ export async function OPTIONS() { /** * POST /api/sandboxes * - * Creates a new ephemeral sandbox environment. Optionally executes a command. - * Sandboxes are isolated Linux microVMs that can be used to evaluate - * account-generated code, run AI agent output safely, or execute reproducible tasks. - * The sandbox will automatically stop after the timeout period. + * Creates a new ephemeral sandbox environment. Sandboxes are isolated Linux + * microVMs used to evaluate account-generated code or run AI agent output + * safely. The sandbox automatically stops after the timeout period. + * + * The OpenClaw `prompt` mode (which offloaded to the `run-sandbox-command` + * task) was retired (recoupable/chat#1813) — async agent work now runs on the + * durable `runAgentWorkflow` via `POST /api/chat/runs`. * * Authentication: x-api-key header or Authorization Bearer token required. * * Request body: - * - command: string (optional) - The command to execute in the sandbox. If omitted, sandbox is created without running any command. - * - args: string[] (optional) - Arguments to pass to the command - * - cwd: string (optional) - Working directory for command execution + * - account_id: string (optional, org keys only) - UUID of the account to create for * * Response (200): * - status: "success" - * - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt, runId? }] - * - runId is only included when a command was provided + * - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt }] * * Error (400/401): * - status: "error" diff --git a/lib/chat/__tests__/const.test.ts b/lib/chat/__tests__/const.test.ts index 2957c3fb0..f11c659ca 100644 --- a/lib/chat/__tests__/const.test.ts +++ b/lib/chat/__tests__/const.test.ts @@ -2,21 +2,13 @@ import { describe, it, expect } from "vitest"; import { SYSTEM_PROMPT } from "../const"; describe("SYSTEM_PROMPT", () => { - describe("release routing", () => { - it("includes release management in prompt_sandbox bullet points", () => { - expect(SYSTEM_PROMPT).toContain( - "**All release management** — creating releases, updating release info, checking release status, adding tracks, DSP pitches, marketing plans", - ); - }); - - it("explicitly warns against using create_knowledge_base for releases", () => { - expect(SYSTEM_PROMPT).toContain( - "Do NOT use create_knowledge_base for release information, track listings, or release plans", - ); - }); + it("no longer references the retired prompt_sandbox tool (chat#1813)", () => { + expect(SYSTEM_PROMPT).not.toContain("prompt_sandbox"); + expect(SYSTEM_PROMPT).not.toContain("Sandbox-First"); + }); - it("directs release-related tasks to prompt_sandbox", () => { - expect(SYSTEM_PROMPT).toContain("always use prompt_sandbox for anything release-related"); - }); + it("retains the core agent framing", () => { + expect(SYSTEM_PROMPT).toContain("You are Recoup"); + expect(SYSTEM_PROMPT).toContain("# Core Expertise"); }); }); diff --git a/lib/chat/const.ts b/lib/chat/const.ts index 54daa63d4..7e1772190 100644 --- a/lib/chat/const.ts +++ b/lib/chat/const.ts @@ -17,22 +17,6 @@ export const SYSTEM_PROMPT = `You are Recoup, a friendly, sharp, and strategic A --- -# Sandbox-First Approach - -You have a persistent sandbox environment via the **prompt_sandbox** tool. **This is your primary tool.** Use it for: -- Any task involving files, code, data analysis, or content generation -- Creating and editing documents, reports, spreadsheets, or marketing materials -- Building release plans, campaign briefs, or strategy decks -- Generating visualizations, charts, or formatted outputs -- Any multi-step or complex task that benefits from a working environment -- **All release management** — creating releases, updating release info, checking release status, adding tracks, DSP pitches, marketing plans - -**Default to prompt_sandbox unless a different tool is clearly better suited.** Other tools are best for quick, single-purpose lookups or updates (e.g., fetching Spotify data, searching the web, editing an image). When in doubt, use the sandbox. - -**IMPORTANT:** Do NOT use create_knowledge_base for release information, track listings, or release plans. The sandbox has a release management skill that maintains structured RELEASE.md documents — always use prompt_sandbox for anything release-related. - ---- - # Core Expertise You specialize in artist management, fan analysis, marketing funnels, social media strategy, and platform optimization across Spotify, TikTok, Instagram, YouTube, and more. diff --git a/lib/mcp/tools/files/__tests__/registerCreateKnowledgeBaseTool.test.ts b/lib/mcp/tools/files/__tests__/registerCreateKnowledgeBaseTool.test.ts index 5414aa824..a51a75452 100644 --- a/lib/mcp/tools/files/__tests__/registerCreateKnowledgeBaseTool.test.ts +++ b/lib/mcp/tools/files/__tests__/registerCreateKnowledgeBaseTool.test.ts @@ -41,8 +41,8 @@ describe("registerCreateKnowledgeBaseTool", () => { expect(registeredDescription).toContain("NOT for releases, tracks, marketing plans"); }); - it("redirects structured data to prompt_sandbox", () => { - expect(registeredDescription).toContain("use prompt_sandbox for those"); + it("no longer references the retired prompt_sandbox tool (chat#1813)", () => { + expect(registeredDescription).not.toContain("prompt_sandbox"); }); it("does not mention adding knowledge base files", () => { diff --git a/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts b/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts index 4c827cdeb..4870c1d0b 100644 --- a/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts +++ b/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts @@ -29,7 +29,7 @@ export function registerCreateKnowledgeBaseTool(server: McpServer): void { server.registerTool( "create_knowledge_base", { - description: `Saves a plain-text knowledge base entry to the artist's permanent storage on Arweave. Use ONLY for general reference notes, bios, or background context — NOT for releases, tracks, marketing plans, or any structured data (use prompt_sandbox for those).`, + description: `Saves a plain-text knowledge base entry to the artist's permanent storage on Arweave. Use ONLY for general reference notes, bios, or background context — NOT for releases, tracks, marketing plans, or any structured data.`, inputSchema: createKnowledgeBaseSchema, }, async (args: CreateKnowledgeBaseArgs) => { diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 6332dc248..2079e27c4 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -20,7 +20,6 @@ import { registerSendEmailTool } from "./registerSendEmailTool"; import { registerAllArtistTools } from "./artists"; import { registerAllChatsTools } from "./chats"; import { registerAllPulseTools } from "./pulse"; -import { registerAllSandboxTools } from "./sandbox"; /** * Registers all MCP tools on the server. @@ -40,7 +39,6 @@ export const registerAllTools = (server: McpServer): void => { registerAllFlamingoTools(server); registerAllImageTools(server); registerAllPulseTools(server); - registerAllSandboxTools(server); registerAllSearchTools(server); registerAllSora2Tools(server); registerAllSpotifyTools(server); diff --git a/lib/mcp/tools/sandbox/__tests__/registerPromptSandboxTool.test.ts b/lib/mcp/tools/sandbox/__tests__/registerPromptSandboxTool.test.ts deleted file mode 100644 index 054b08073..000000000 --- a/lib/mcp/tools/sandbox/__tests__/registerPromptSandboxTool.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; - -import { registerPromptSandboxTool } from "../registerPromptSandboxTool"; - -const mockProcessCreateSandbox = vi.fn(); -const mockResolveAccountId = vi.fn(); - -vi.mock("@/lib/sandbox/processCreateSandbox", () => ({ - processCreateSandbox: (...args: unknown[]) => mockProcessCreateSandbox(...args), -})); - -vi.mock("@/lib/mcp/resolveAccountId", () => ({ - resolveAccountId: (...args: unknown[]) => mockResolveAccountId(...args), -})); - -type ServerRequestHandlerExtra = RequestHandlerExtra; - -/** - * Creates a mock extra object with optional authInfo. - * - * @param authInfo - * @param authInfo.accountId - * @param authInfo.orgId - */ -function createMockExtra(authInfo?: { - accountId?: string; - orgId?: string | null; -}): ServerRequestHandlerExtra { - return { - authInfo: authInfo - ? { - token: "test-token", - scopes: ["mcp:tools"], - clientId: authInfo.accountId, - extra: { - accountId: authInfo.accountId, - orgId: authInfo.orgId ?? null, - }, - } - : undefined, - } as unknown as ServerRequestHandlerExtra; -} - -describe("registerPromptSandboxTool", () => { - let mockServer: McpServer; - let registeredHandler: (args: unknown, extra: ServerRequestHandlerExtra) => Promise; - - beforeEach(() => { - vi.clearAllMocks(); - - mockServer = { - registerTool: vi.fn((name, config, handler) => { - registeredHandler = handler; - }), - } as unknown as McpServer; - - registerPromptSandboxTool(mockServer); - }); - - it("registers the prompt_sandbox tool", () => { - expect(mockServer.registerTool).toHaveBeenCalledWith( - "prompt_sandbox", - expect.objectContaining({ - description: expect.any(String), - }), - expect.any(Function), - ); - }); - - it("returns error when resolveAccountId returns an error", async () => { - mockResolveAccountId.mockResolvedValue({ - accountId: null, - error: - "Authentication required. Provide an API key via Authorization: Bearer header, or provide account_id from the system prompt context.", - }); - - const result = await registeredHandler({ prompt: "say hello" }, createMockExtra()); - - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining("Authentication required"), - }, - ], - }); - }); - - it("returns error when resolveAccountId returns null accountId without error", async () => { - mockResolveAccountId.mockResolvedValue({ - accountId: null, - error: null, - }); - - const result = await registeredHandler({ prompt: "say hello" }, createMockExtra()); - - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining("Failed to resolve account ID"), - }, - ], - }); - }); - - it("calls processCreateSandbox with prompt and returns success", async () => { - mockResolveAccountId.mockResolvedValue({ - accountId: "acc_123", - error: null, - }); - mockProcessCreateSandbox.mockResolvedValue({ - sandboxId: "sbx_456", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - runId: "run_prompt456", - }); - - const result = await registeredHandler( - { prompt: "create a hello world index.html" }, - createMockExtra({ accountId: "acc_123" }), - ); - - expect(mockProcessCreateSandbox).toHaveBeenCalledWith({ - accountId: "acc_123", - prompt: "create a hello world index.html", - }); - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining('"sandboxId":"sbx_456"'), - }, - ], - }); - }); - - it("passes account_id as accountIdOverride to resolveAccountId", async () => { - mockResolveAccountId.mockResolvedValue({ - accountId: "user_456", - error: null, - }); - mockProcessCreateSandbox.mockResolvedValue({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }); - - const extra = createMockExtra({ accountId: "org_123", orgId: "org_123" }); - await registeredHandler({ prompt: "say hello", account_id: "user_456" }, extra); - - expect(mockResolveAccountId).toHaveBeenCalledWith({ - authInfo: extra.authInfo, - accountIdOverride: "user_456", - }); - }); - - it("passes undefined accountIdOverride when no account_id arg provided", async () => { - mockResolveAccountId.mockResolvedValue({ - accountId: "acc_123", - error: null, - }); - mockProcessCreateSandbox.mockResolvedValue({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }); - - const extra = createMockExtra({ accountId: "acc_123" }); - await registeredHandler({ prompt: "say hello" }, extra); - - expect(mockResolveAccountId).toHaveBeenCalledWith({ - authInfo: extra.authInfo, - accountIdOverride: undefined, - }); - }); - - it("returns error when processCreateSandbox throws", async () => { - mockResolveAccountId.mockResolvedValue({ - accountId: "acc_123", - error: null, - }); - mockProcessCreateSandbox.mockRejectedValue(new Error("Sandbox creation failed")); - - const result = await registeredHandler( - { prompt: "say hello" }, - createMockExtra({ accountId: "acc_123" }), - ); - - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining("Sandbox creation failed"), - }, - ], - }); - }); -}); diff --git a/lib/mcp/tools/sandbox/index.ts b/lib/mcp/tools/sandbox/index.ts deleted file mode 100644 index ffb5d8431..000000000 --- a/lib/mcp/tools/sandbox/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerPromptSandboxTool } from "./registerPromptSandboxTool"; - -/** - * Registers all sandbox-related MCP tools on the server. - * - * @param server - The MCP server instance to register tools on. - */ -export const registerAllSandboxTools = (server: McpServer): void => { - registerPromptSandboxTool(server); -}; diff --git a/lib/mcp/tools/sandbox/registerPromptSandboxTool.ts b/lib/mcp/tools/sandbox/registerPromptSandboxTool.ts deleted file mode 100644 index cd0ec5fba..000000000 --- a/lib/mcp/tools/sandbox/registerPromptSandboxTool.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { processCreateSandbox } from "@/lib/sandbox/processCreateSandbox"; - -const promptSandboxSchema = z.object({ - prompt: z - .string() - .describe( - 'A prompt to pass to OpenClaw. Runs `openclaw agent --agent main --message ""` in the sandbox.', - ), - account_id: z - .string() - .optional() - .describe( - "The account ID to run the sandbox command for. Only applicable for organization API keys — org keys can target any account within their organization. Do not use with personal API keys.", - ), -}); - -/** - * Registers the "prompt_sandbox" tool on the MCP server. - * Creates a sandbox and runs an OpenClaw prompt in it. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerPromptSandboxTool(server: McpServer): void { - server.registerTool( - "prompt_sandbox", - { - description: - 'Create a sandbox and run an OpenClaw prompt in it. Runs `openclaw agent --agent main --message ""`. Returns the sandbox ID and a run ID to track progress.', - inputSchema: promptSandboxSchema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: args.account_id, - }); - - if (error) { - return getToolResultError(error); - } - - if (!accountId) { - return getToolResultError("Failed to resolve account ID"); - } - - try { - const result = await processCreateSandbox({ - accountId, - prompt: args.prompt, - }); - - return getToolResultSuccess(result); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create sandbox"; - return getToolResultError(message); - } - }, - ); -} diff --git a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts index 03aafaaf4..fe97e2ae2 100644 --- a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts @@ -41,7 +41,7 @@ describe("createSandboxPostHandler", () => { expect(response.status).toBe(401); }); - it("returns 200 with sandbox result when no prompt", async () => { + it("returns 200 with the sandbox result", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, @@ -72,35 +72,11 @@ describe("createSandboxPostHandler", () => { }); }); - it("returns runId when prompt is provided", async () => { - vi.mocked(validateSandboxBody).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - prompt: "create a hello world page", - }); - vi.mocked(processCreateSandbox).mockResolvedValue({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - runId: "run_abc123", - }); - - const request = createMockRequest(); - const response = await createSandboxPostHandler(request); - - expect(response.status).toBe(200); - const json = await response.json(); - expect(json.sandboxes[0].runId).toBe("run_abc123"); - }); - it("passes validated input to processCreateSandbox", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - prompt: "say hello", }); vi.mocked(processCreateSandbox).mockResolvedValue({ sandboxId: "sbx_123", @@ -116,7 +92,6 @@ describe("createSandboxPostHandler", () => { accountId: "acc_123", orgId: null, authToken: "token", - prompt: "say hello", }); }); diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts deleted file mode 100644 index 789b0a998..000000000 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { VercelSandbox } from "@/lib/sandbox/vercel"; - -import { processCreateSandbox } from "../processCreateSandbox"; -import { createSandboxFromSnapshot } from "@/lib/sandbox/createSandboxFromSnapshot"; -import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; - -vi.mock("@/lib/sandbox/createSandboxFromSnapshot", () => ({ - createSandboxFromSnapshot: vi.fn(), -})); - -vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ - triggerPromptSandbox: vi.fn(), -})); - -const mockSandbox = { - name: "sbx_123", - sdkStatus: "running", - timeout: 600000, - createdAt: new Date("2024-01-01T00:00:00.000Z"), -} as unknown as VercelSandbox; - -describe("processCreateSandbox", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(createSandboxFromSnapshot).mockResolvedValue({ - sandbox: mockSandbox, - fromSnapshot: false, - }); - }); - - it("delegates to createSandboxFromSnapshot", async () => { - await processCreateSandbox({ accountId: "acc_123" }); - - expect(createSandboxFromSnapshot).toHaveBeenCalledWith("acc_123"); - }); - - it("returns serializable response without runId when no prompt", async () => { - const result = await processCreateSandbox({ accountId: "acc_123" }); - - expect(result).toEqual({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }); - expect(triggerPromptSandbox).not.toHaveBeenCalled(); - }); - - it("returns result with runId when prompt is provided", async () => { - vi.mocked(triggerPromptSandbox).mockResolvedValue({ - id: "run_prompt123", - }); - - const result = await processCreateSandbox({ - accountId: "acc_123", - prompt: "create a hello world index.html", - }); - - expect(result).toEqual({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - runId: "run_prompt123", - }); - expect(triggerPromptSandbox).toHaveBeenCalledWith({ - prompt: "create a hello world index.html", - sandboxId: "sbx_123", - accountId: "acc_123", - }); - }); - - it("throws when createSandboxFromSnapshot fails", async () => { - vi.mocked(createSandboxFromSnapshot).mockRejectedValue(new Error("Sandbox creation failed")); - - await expect(processCreateSandbox({ accountId: "acc_123" })).rejects.toThrow( - "Sandbox creation failed", - ); - }); - - it("returns result without runId when triggerPromptSandbox fails", async () => { - vi.mocked(triggerPromptSandbox).mockRejectedValue(new Error("Task trigger failed")); - - const result = await processCreateSandbox({ - accountId: "acc_123", - prompt: "say hello", - }); - - expect(result).toEqual({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }); - }); -}); diff --git a/lib/sandbox/__tests__/validateSandboxBody.test.ts b/lib/sandbox/__tests__/validateSandboxBody.test.ts index 122887e62..e21052eb3 100644 --- a/lib/sandbox/__tests__/validateSandboxBody.test.ts +++ b/lib/sandbox/__tests__/validateSandboxBody.test.ts @@ -42,13 +42,13 @@ describe("validateSandboxBody", () => { expect((result as NextResponse).status).toBe(401); }); - it("returns validated body with auth context when prompt is provided", async () => { + it("returns the validated body with auth context", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: "org_456", authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ prompt: "say hello" }); + vi.mocked(safeParseJson).mockResolvedValue({}); const request = createMockRequest(); const result = await validateSandboxBody(request); @@ -57,11 +57,10 @@ describe("validateSandboxBody", () => { accountId: "acc_123", orgId: "org_456", authToken: "token", - prompt: "say hello", }); }); - it("strips unknown fields from body", async () => { + it("strips unknown fields from body (including the retired prompt)", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: "org_456", @@ -81,11 +80,10 @@ describe("validateSandboxBody", () => { accountId: "acc_123", orgId: "org_456", authToken: "token", - prompt: "say hello", }); }); - it("returns validated body when command is omitted (optional)", async () => { + it("returns validated body when body is empty", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: null, @@ -103,37 +101,6 @@ describe("validateSandboxBody", () => { }); }); - it("returns validated body when prompt is provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: "org_456", - authToken: "token", - }); - vi.mocked(safeParseJson).mockResolvedValue({ - prompt: "create a hello world index.html", - }); - - const request = createMockRequest(); - const result = await validateSandboxBody(request); - - expect(result).toEqual({ - accountId: "acc_123", - orgId: "org_456", - authToken: "token", - prompt: "create a hello world index.html", - }); - }); - - it("returns error response when prompt is empty string", async () => { - vi.mocked(safeParseJson).mockResolvedValue({ prompt: "" }); - - const request = createMockRequest(); - const result = await validateSandboxBody(request); - - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); - }); - it("passes body account_id to validateAuthContext for override", async () => { const targetAccountId = "10000000-1000-4000-8000-100000000001"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -141,10 +108,7 @@ describe("validateSandboxBody", () => { orgId: "org_456", authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ - account_id: targetAccountId, - prompt: "hello", - }); + vi.mocked(safeParseJson).mockResolvedValue({ account_id: targetAccountId }); const request = createMockRequest(); const result = await validateSandboxBody(request); @@ -163,10 +127,7 @@ describe("validateSandboxBody", () => { orgId: "org_456", authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ - account_id: targetAccountId, - prompt: "hello", - }); + vi.mocked(safeParseJson).mockResolvedValue({ account_id: targetAccountId }); const request = createMockRequest(); const result = await validateSandboxBody(request); @@ -176,7 +137,6 @@ describe("validateSandboxBody", () => { accountId: targetAccountId, orgId: "org_456", authToken: "token", - prompt: "hello", }); }); @@ -199,10 +159,7 @@ describe("validateSandboxBody", () => { it("returns 403 when validateAuthContext rejects account_id override", async () => { const targetAccountId = "20000000-2000-4000-8000-200000000002"; - vi.mocked(safeParseJson).mockResolvedValue({ - account_id: targetAccountId, - prompt: "hello", - }); + vi.mocked(safeParseJson).mockResolvedValue({ account_id: targetAccountId }); vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json({ status: "error", error: "Access denied" }, { status: 403 }), ); diff --git a/lib/sandbox/createSandboxPostHandler.ts b/lib/sandbox/createSandboxPostHandler.ts index e953b88a0..9c365b7c7 100644 --- a/lib/sandbox/createSandboxPostHandler.ts +++ b/lib/sandbox/createSandboxPostHandler.ts @@ -8,13 +8,14 @@ import { processCreateSandbox } from "@/lib/sandbox/processCreateSandbox"; * Handler for POST /api/sandboxes. * * Creates a Vercel Sandbox (from account's snapshot if available, otherwise fresh). - * If a prompt is provided, triggers a task to run the prompt via OpenClaw. - * If no prompt is provided, simply creates the sandbox without running anything. * Requires authentication via x-api-key header or Authorization Bearer token. * Saves sandbox info to the account_sandboxes table. * + * The OpenClaw `prompt` mode was retired (recoupable/chat#1813) — async agent + * work now runs on the durable `runAgentWorkflow` via `POST /api/chat/runs`. + * * @param request - The request object - * @returns A NextResponse with sandbox creation result (includes runId only if prompt was provided) + * @returns A NextResponse with the sandbox creation result */ export async function createSandboxPostHandler(request: NextRequest): Promise { const validated = await validateSandboxBody(request); diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index e3cd2a71c..1af6a0dbc 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -1,52 +1,34 @@ import { createSandboxFromSnapshot } from "@/lib/sandbox/createSandboxFromSnapshot"; -import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; import type { SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; type ProcessCreateSandboxInput = { accountId: string; - prompt?: string; }; -type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string }; /** - * Shared domain logic for creating a sandbox and optionally running a prompt. - * Used by both POST /api/sandboxes handler and the prompt_sandbox MCP tool. + * Shared domain logic for `POST /api/sandboxes`: create a sandbox (from the + * account's snapshot if available, otherwise fresh) and shape the response. * - * @param input - The sandbox creation parameters - * @returns The sandbox creation result with optional runId + * The OpenClaw `prompt` mode — which offloaded to the `run-sandbox-command` + * Trigger.dev task via `triggerPromptSandbox` — has been retired + * (recoupable/chat#1813). Async agent work now runs on the durable + * `runAgentWorkflow` via `POST /api/chat/runs`; this endpoint only + * provisions a bare sandbox. + * + * @param input - The sandbox creation parameters. + * @returns The sandbox creation result. */ export async function processCreateSandbox( input: ProcessCreateSandboxInput, -): Promise { - const { accountId, prompt } = input; +): Promise { + const { accountId } = input; const { sandbox } = await createSandboxFromSnapshot(accountId); - const result: SandboxCreatedResponse = { + return { sandboxId: sandbox.name, sandboxStatus: sandbox.sdkStatus, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), }; - - // Trigger the prompt execution task if a prompt was provided - let runId: string | undefined; - if (prompt) { - try { - const handle = await triggerPromptSandbox({ - prompt, - sandboxId: sandbox.name, - accountId, - }); - runId = handle.id; - } catch (triggerError) { - console.error("Failed to trigger prompt sandbox task:", triggerError); - runId = undefined; - } - } - - return { - ...result, - ...(runId && { runId }), - }; } diff --git a/lib/sandbox/validateSandboxBody.ts b/lib/sandbox/validateSandboxBody.ts index ccde70e50..0fc35811e 100644 --- a/lib/sandbox/validateSandboxBody.ts +++ b/lib/sandbox/validateSandboxBody.ts @@ -7,7 +7,6 @@ import { z } from "zod"; export const sandboxBodySchema = z.object({ account_id: z.string().uuid("account_id must be a valid UUID").optional(), - prompt: z.string().min(1, "prompt cannot be empty").optional(), }); export type SandboxBody = z.infer & AuthContext; diff --git a/lib/trigger/triggerPromptSandbox.ts b/lib/trigger/triggerPromptSandbox.ts deleted file mode 100644 index ea4623fee..000000000 --- a/lib/trigger/triggerPromptSandbox.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { tasks } from "@trigger.dev/sdk"; - -type PromptSandboxPayload = { - prompt: string; - sandboxId: string; - accountId: string; -}; - -/** - * Triggers the run-sandbox-command task to execute an OpenClaw prompt in a sandbox. - * - * @param payload - The task payload with prompt, sandboxId, and accountId - * @returns The task handle with runId - */ -export async function triggerPromptSandbox(payload: PromptSandboxPayload) { - const handle = await tasks.trigger("run-sandbox-command", { - command: "openclaw", - args: ["agent", "--agent", "main", "--message", payload.prompt], - sandboxId: payload.sandboxId, - accountId: payload.accountId, - }); - return handle; -} From 4ecca4442b6e1d21310e975adbcf48d918e3f16d Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 25 Jun 2026 14:33:56 -0500 Subject: [PATCH 17/27] feat: POST /api/emails + route ephemeral key to RECOUP_API_KEY (#1815) (#708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(emails): POST /api/emails + route ephemeral key to RECOUP_API_KEY (#1815) Item 1 of recoupable/chat#1815 — let the sandbox agent (and scheduled report tasks) deliver email. POST /api/emails: send an email to explicit recipients, account-scoped via validateAuthContext, reusing the same processAndSendEmail domain fn as the send_email MCP tool (DRY). Mirrors POST /api/notifications but takes a required `to[]`. SRP: route → sendEmailHandler → validateSendEmailBody. Flat response { success, message, id }; 400/401/502 like the sibling. TDD red→green. buildRecoupExecEnv: route a recoup_sk_ token (the headless /api/chat/runs ephemeral key) to RECOUP_API_KEY (which the recoup-api skill sends as x-api-key) instead of RECOUP_ACCESS_TOKEN (Bearer). REST endpoints 401 a recoup_sk_ key over Bearer — this is why the sandbox agent's recoup-api calls were failing. Privy JWTs (interactive path) still route to RECOUP_ACCESS_TOKEN. Verified by diagnostic run: x-api-key → 200, Bearer → 401. Contract: recoupable/docs#251. Affected suites green (231); my files tsc + lint clean (other tsc errors pre-exist on test). Co-Authored-By: Claude Opus 4.8 (1M context) * feat: bring POST /api/emails to parity with docs#251 contract Documentation-driven follow-up to the merged docs#251 contract: 1. Rename the public request field room_id -> chat_id at the /api/emails boundary (schema, type, handler, route JSDoc). The internal processAndSendEmail/selectRoomWithArtist plumbing keeps room_id (same id value, rooms table) so the shared MCP send_email path is untouched. 2. Enforce the recipient restriction: without a payment method on file, to/cc are limited to the account's own email (403 otherwise); a card on file lifts it. New assertRecipientsAllowed + accountHasPaymentMethod helpers (read-only Stripe customer + default-payment-method lookup). Tests: assertRecipientsAllowed unit (card-on-file, own-email, blocked), handler chat_id mapping + 403 path, validate chat_id. 144 emails/notifications tests green; tsc adds 0 new errors; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(emails): address review — server-side token parsing, DRY, SRP Addresses the four review comments on api#708: 1. KISS (buildRecoupExecEnv): drop client-side token routing. The server now accepts a `recoup_sk_` API key over `Authorization: Bearer` too (getAuthenticatedAccountId parses the format), so buildRecoupExecEnv always sets a single RECOUP_ACCESS_TOKEN. New shared getAccountIdByApiKey is used by both the x-api-key and Bearer paths. 2. DRY (payment method): extract accountHasPaymentMethod into lib/stripe and reuse it in ensureSongstatsPaymentMethod (was duplicating the findStripeCustomer -> findDefaultPaymentMethod two-step). 3. SRP: move the recipient restriction out of the handler into validateSendEmailBody (alongside auth/validation). 4. KISS: validateSendEmailBody returns { ...result.data, accountId }. Tests: getAuthenticatedAccountId recoup_sk_ branch, recipient 403 moved to the validator suite, handler test now mocks the validator. 427 tests green across emails/auth/stripe/agent/research; tsc 0 new errors; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/emails/route.ts | 46 +++++ .../__tests__/buildRecoupExecEnv.test.ts | 21 +++ lib/agent/tools/buildRecoupExecEnv.ts | 14 +- .../getAuthenticatedAccountId.test.ts | 55 ++++++ lib/auth/getAccountIdByApiKey.ts | 35 ++++ lib/auth/getApiKeyAccountId.ts | 71 ++------ lib/auth/getAuthenticatedAccountId.ts | 26 ++- .../__tests__/assertRecipientsAllowed.test.ts | 58 ++++++ lib/emails/__tests__/sendEmailHandler.test.ts | 81 +++++++++ .../__tests__/validateSendEmailBody.test.ts | 172 ++++++++++++++++++ lib/emails/assertRecipientsAllowed.ts | 40 ++++ lib/emails/sendEmailHandler.ts | 48 +++++ lib/emails/validateSendEmailBody.ts | 82 +++++++++ .../ensureSongstatsPaymentMethod.ts | 7 +- lib/stripe/accountHasPaymentMethod.ts | 21 +++ 15 files changed, 703 insertions(+), 74 deletions(-) create mode 100644 app/api/emails/route.ts create mode 100644 lib/auth/__tests__/getAuthenticatedAccountId.test.ts create mode 100644 lib/auth/getAccountIdByApiKey.ts create mode 100644 lib/emails/__tests__/assertRecipientsAllowed.test.ts create mode 100644 lib/emails/__tests__/sendEmailHandler.test.ts create mode 100644 lib/emails/__tests__/validateSendEmailBody.test.ts create mode 100644 lib/emails/assertRecipientsAllowed.ts create mode 100644 lib/emails/sendEmailHandler.ts create mode 100644 lib/emails/validateSendEmailBody.ts create mode 100644 lib/stripe/accountHasPaymentMethod.ts diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts new file mode 100644 index 000000000..df3555ae8 --- /dev/null +++ b/app/api/emails/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { sendEmailHandler } from "@/lib/emails/sendEmailHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/emails + * + * Sends an email to one or more explicit recipients via Resend. Emails are sent + * from `Agent by Recoup `. Account-scoped — requires + * authentication via x-api-key header or Authorization Bearer token. + * + * Body parameters: + * - to (required): array of recipient email addresses + * - subject (required): email subject line + * - text (optional): plain text / Markdown body + * - html (optional): raw HTML body (takes precedence over text) + * - cc (optional): array of CC email addresses + * - headers (optional): custom email headers + * - chat_id (optional): chat ID for a chat link in the footer + * - account_id (optional): UUID of the account to send for (org keys only) + * + * Recipient restriction: without a payment method on file, to/cc are limited to + * the account's own email; a card on file lifts the restriction (403 otherwise). + * + * @param request - The request object. + * @returns A NextResponse with the send result. + */ +export async function POST(request: NextRequest): Promise { + return sendEmailHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts b/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts index c17374ea1..da22ece5b 100644 --- a/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts +++ b/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts @@ -62,4 +62,25 @@ describe("buildRecoupExecEnv", () => { RECOUP_ACCESS_TOKEN: "jwt.value", }); }); + + // recoupable/chat#1815: both a Privy JWT and an ephemeral `recoup_sk_` API + // key surface as RECOUP_ACCESS_TOKEN (Bearer). The server parses the token + // format and authenticates a `recoup_sk_` key over Bearer too, so there is no + // client-side x-api-key routing here. + it("surfaces a recoup_sk_ API key as RECOUP_ACCESS_TOKEN (Bearer)", () => { + const env = buildRecoupExecEnv({ + sandbox: baseSandbox, + recoupAccessToken: "recoup_sk_abc123", + }); + expect(env).toEqual({ RECOUP_ACCESS_TOKEN: "recoup_sk_abc123" }); + }); + + it("injects RECOUP_ORG_ID alongside a recoup_sk_ key", () => { + const env = buildRecoupExecEnv({ + sandbox: baseSandbox, + recoupOrgId: "org-uuid", + recoupAccessToken: "recoup_sk_xyz", + }); + expect(env).toEqual({ RECOUP_ORG_ID: "org-uuid", RECOUP_ACCESS_TOKEN: "recoup_sk_xyz" }); + }); }); diff --git a/lib/agent/tools/buildRecoupExecEnv.ts b/lib/agent/tools/buildRecoupExecEnv.ts index 67d4849bf..e185f237b 100644 --- a/lib/agent/tools/buildRecoupExecEnv.ts +++ b/lib/agent/tools/buildRecoupExecEnv.ts @@ -7,12 +7,14 @@ import { isAgentContext } from "@/lib/agent/tools/isAgentContext"; * * Injects: * - `RECOUP_ORG_ID` — public organization UUID. Always safe. - * - `RECOUP_ACCESS_TOKEN` — short-lived Privy JWT, when the handler - * plumbed one through `AgentContext.recoupAccessToken`. Used by the - * `recoup-api` skill's curl examples to authenticate as the user. - * Long-lived api keys are deliberately NOT forwarded — only the - * short-lived bearer token is, and only when the caller used - * bearer auth (the handler enforces that gating). + * - `RECOUP_ACCESS_TOKEN` — the short-lived credential from + * `AgentContext.recoupAccessToken`, which the `recoup-api` skill sends as + * `Authorization: Bearer`. This may be a Privy JWT (interactive path) or an + * ephemeral `recoup_sk_` API key (headless `/api/chat/runs`) — the server + * parses the format and authenticates either over Bearer, so there's no + * client-side routing here (recoupable/chat#1815). Long-lived api keys are + * deliberately NOT forwarded — only the short-lived credential the handler + * plumbed through. * * Returns `undefined` when nothing is available to inject so callers can * cleanly spread a conditional `...(env ? { env } : {})` into exec opts. diff --git a/lib/auth/__tests__/getAuthenticatedAccountId.test.ts b/lib/auth/__tests__/getAuthenticatedAccountId.test.ts new file mode 100644 index 000000000..51316c6db --- /dev/null +++ b/lib/auth/__tests__/getAuthenticatedAccountId.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getAccountIdByApiKey } from "@/lib/auth/getAccountIdByApiKey"; +import { getOrCreateAccountIdByAuthToken } from "@/lib/privy/getOrCreateAccountIdByAuthToken"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); +vi.mock("@/lib/auth/getAccountIdByApiKey", () => ({ getAccountIdByApiKey: vi.fn() })); +vi.mock("@/lib/privy/getOrCreateAccountIdByAuthToken", () => ({ + getOrCreateAccountIdByAuthToken: vi.fn(), +})); + +function req(bearer?: string) { + const headers = new Headers(); + if (bearer) headers.set("authorization", `Bearer ${bearer}`); + return new NextRequest("https://x.test/api", { headers }); +} + +describe("getAuthenticatedAccountId", () => { + beforeEach(() => vi.clearAllMocks()); + + it("401 when no bearer token", async () => { + const res = await getAuthenticatedAccountId(req()); + expect((res as Response).status).toBe(401); + }); + + it("validates a recoup_sk_ Bearer token as an API key (no Privy call)", async () => { + vi.mocked(getAccountIdByApiKey).mockResolvedValue("acc-key"); + + const res = await getAuthenticatedAccountId(req("recoup_sk_abc")); + + expect(res).toBe("acc-key"); + expect(getAccountIdByApiKey).toHaveBeenCalledWith("recoup_sk_abc"); + expect(getOrCreateAccountIdByAuthToken).not.toHaveBeenCalled(); + }); + + it("401 when a recoup_sk_ Bearer key is unknown/expired", async () => { + vi.mocked(getAccountIdByApiKey).mockResolvedValue(null); + + const res = await getAuthenticatedAccountId(req("recoup_sk_bad")); + + expect((res as Response).status).toBe(401); + expect(getOrCreateAccountIdByAuthToken).not.toHaveBeenCalled(); + }); + + it("treats a non-recoup_sk_ token as a Privy JWT (no API-key call)", async () => { + vi.mocked(getOrCreateAccountIdByAuthToken).mockResolvedValue("acc-privy"); + + const res = await getAuthenticatedAccountId(req("eyJhbGci.jwt.value")); + + expect(res).toBe("acc-privy"); + expect(getOrCreateAccountIdByAuthToken).toHaveBeenCalledWith("eyJhbGci.jwt.value"); + expect(getAccountIdByApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/auth/getAccountIdByApiKey.ts b/lib/auth/getAccountIdByApiKey.ts new file mode 100644 index 000000000..eba083d4f --- /dev/null +++ b/lib/auth/getAccountIdByApiKey.ts @@ -0,0 +1,35 @@ +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired"; +import { PRIVY_PROJECT_SECRET } from "@/lib/const"; +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; + +/** + * Resolve the account id for a raw Recoup API key (`recoup_sk_…`), or `null` + * when the key is unknown, the lookup fails, or the key is past its `expires_at` + * TTL (ephemeral keys — chat#1813). + * + * Shared by both auth entry points so a `recoup_sk_` key authenticates the same + * way whether it arrives as `x-api-key` (`getApiKeyAccountId`) or as + * `Authorization: Bearer` (`getAuthenticatedAccountId`). + * + * @param apiKey - The raw API key string. + */ +export async function getAccountIdByApiKey(apiKey: string): Promise { + const keyHash = hashApiKey(apiKey, PRIVY_PROJECT_SECRET); + const apiKeys = await selectAccountApiKeys({ keyHash }); + + if (apiKeys === null) { + console.error("[ERROR] selectAccountApiKeys returned null"); + return null; + } + + const matched = apiKeys[0]; + const accountId = matched?.account ?? null; + + // Reject an unknown key, or an ephemeral key past its TTL. + if (!accountId || isApiKeyExpired(matched?.expires_at)) { + return null; + } + + return accountId; +} diff --git a/lib/auth/getApiKeyAccountId.ts b/lib/auth/getApiKeyAccountId.ts index a5dce9ed6..a47239895 100644 --- a/lib/auth/getApiKeyAccountId.ts +++ b/lib/auth/getApiKeyAccountId.ts @@ -1,13 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { hashApiKey } from "@/lib/keys/hashApiKey"; -import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired"; -import { PRIVY_PROJECT_SECRET } from "@/lib/const"; -import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; +import { getAccountIdByApiKey } from "@/lib/auth/getAccountIdByApiKey"; /** - * Extracts and validates the API key from the request, - * then returns the associated account ID. + * Extracts the API key from the `x-api-key` header and returns the associated + * account ID, delegating the hash/lookup/TTL check to `getAccountIdByApiKey` + * (shared with the Bearer path). * * @param request - The NextRequest object * @returns Either the account ID string, or a NextResponse error if validation fails @@ -17,64 +15,19 @@ export async function getApiKeyAccountId(request: NextRequest): Promise ({ + accountHasPaymentMethod: (...args: unknown[]) => mockAccountHasPaymentMethod(...args), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: (...args: unknown[]) => mockSelectAccountEmails(...args), +})); + +describe("assertRecipientsAllowed", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectAccountEmails.mockResolvedValue([{ email: "owner@account.com" }]); + }); + + it("allows any recipient when a payment method is on file", async () => { + mockAccountHasPaymentMethod.mockResolvedValue(true); + + const result = await assertRecipientsAllowed({ + accountId: "acct-1", + recipients: ["stranger@example.com", "another@example.com"], + }); + + expect(result.allowed).toBe(true); + // No need to look up account emails when a card is on file. + expect(mockSelectAccountEmails).not.toHaveBeenCalled(); + }); + + it("allows the account's own email without a payment method (case-insensitive)", async () => { + mockAccountHasPaymentMethod.mockResolvedValue(false); + + const result = await assertRecipientsAllowed({ + accountId: "acct-1", + recipients: ["OWNER@Account.com"], + }); + + expect(result.allowed).toBe(true); + }); + + it("blocks foreign recipients without a payment method and lists them", async () => { + mockAccountHasPaymentMethod.mockResolvedValue(false); + + const result = await assertRecipientsAllowed({ + accountId: "acct-1", + recipients: ["owner@account.com", "stranger@example.com"], + }); + + expect(result.allowed).toBe(false); + if (result.allowed === false) { + expect(result.disallowed).toEqual(["stranger@example.com"]); + } + }); +}); diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts new file mode 100644 index 000000000..3b5fa6e88 --- /dev/null +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { sendEmailHandler } from "../sendEmailHandler"; + +const mockValidateSendEmailBody = vi.fn(); +const mockProcessAndSendEmail = vi.fn(); + +vi.mock("@/lib/emails/validateSendEmailBody", () => ({ + validateSendEmailBody: (...args: unknown[]) => mockValidateSendEmailBody(...args), +})); + +vi.mock("@/lib/emails/processAndSendEmail", () => ({ + processAndSendEmail: (...args: unknown[]) => mockProcessAndSendEmail(...args), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +function createRequest(): NextRequest { + return new NextRequest("https://recoup-api.vercel.app/api/emails", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: "{}", + }); +} + +describe("sendEmailHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateSendEmailBody.mockResolvedValue({ + to: ["dest@example.com"], + cc: ["cc@example.com"], + subject: "Weekly report", + text: "body", + chat_id: "chat-1", + accountId: "account-123", + }); + mockProcessAndSendEmail.mockResolvedValue({ + success: true, + message: "Email sent successfully.", + id: "resend-id-1", + }); + }); + + it("sends to the validated recipients and maps chat_id to the footer link", async () => { + const response = await sendEmailHandler(createRequest()); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ success: true, message: "Email sent successfully.", id: "resend-id-1" }); + + // Public field is chat_id; processAndSendEmail keeps the internal room_id arg. + expect(mockProcessAndSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["dest@example.com"], + cc: ["cc@example.com"], + subject: "Weekly report", + text: "body", + room_id: "chat-1", + }), + ); + }); + + it("propagates the NextResponse from validateSendEmailBody (auth/validation/recipient errors)", async () => { + mockValidateSendEmailBody.mockResolvedValue( + NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }), + ); + const response = await sendEmailHandler(createRequest()); + expect(response.status).toBe(403); + expect(mockProcessAndSendEmail).not.toHaveBeenCalled(); + }); + + it("returns 502 when Resend delivery fails", async () => { + mockProcessAndSendEmail.mockResolvedValue({ success: false, error: "resend boom" }); + const response = await sendEmailHandler(createRequest()); + expect(response.status).toBe(502); + const json = await response.json(); + expect(json.error).toBe("resend boom"); + }); +}); diff --git a/lib/emails/__tests__/validateSendEmailBody.test.ts b/lib/emails/__tests__/validateSendEmailBody.test.ts new file mode 100644 index 000000000..b4e315b77 --- /dev/null +++ b/lib/emails/__tests__/validateSendEmailBody.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateSendEmailBody } from "../validateSendEmailBody"; + +const mockValidateAuthContext = vi.fn(); +const mockAssertRecipientsAllowed = vi.fn(); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), +})); + +vi.mock("@/lib/emails/assertRecipientsAllowed", () => ({ + assertRecipientsAllowed: (...args: unknown[]) => mockAssertRecipientsAllowed(...args), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(async (req: Request) => req.json()), +})); + +function createRequest(body: unknown, headers: Record = {}): NextRequest { + return new NextRequest("http://localhost/api/emails", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +describe("validateSendEmailBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateAuthContext.mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-api-key", + }); + mockAssertRecipientsAllowed.mockResolvedValue({ allowed: true }); + }); + + describe("recipient restriction", () => { + it("returns 403 with disallowed recipients when the gate blocks", async () => { + mockAssertRecipientsAllowed.mockResolvedValue({ + allowed: false, + disallowed: ["stranger@example.com"], + }); + const request = createRequest( + { to: ["stranger@example.com"], subject: "Hi" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateSendEmailBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const json = await result.json(); + expect(json.disallowed_recipients).toEqual(["stranger@example.com"]); + } + }); + + it("checks to + cc together against the authenticated account", async () => { + const request = createRequest( + { to: ["a@example.com"], cc: ["b@example.com"], subject: "Hi" }, + { "x-api-key": "test-api-key" }, + ); + await validateSendEmailBody(request); + + expect(mockAssertRecipientsAllowed).toHaveBeenCalledWith({ + accountId: "account-123", + recipients: ["a@example.com", "b@example.com"], + }); + }); + }); + + describe("successful validation", () => { + it("returns validated data with to + subject + text", async () => { + const request = createRequest( + { to: ["dest@example.com"], subject: "Hi", text: "Hello world" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateSendEmailBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.to).toEqual(["dest@example.com"]); + expect(result.subject).toBe("Hi"); + expect(result.text).toBe("Hello world"); + expect(result.accountId).toBe("account-123"); + } + }); + + it("returns validated data with all optional fields", async () => { + const request = createRequest( + { + to: ["a@example.com", "b@example.com"], + cc: ["cc@example.com"], + subject: "Subject", + text: "t", + html: "

h

", + headers: { "X-Custom": "v" }, + chat_id: "chat-1", + }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateSendEmailBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.to).toEqual(["a@example.com", "b@example.com"]); + expect(result.cc).toEqual(["cc@example.com"]); + expect(result.html).toBe("

h

"); + expect(result.headers).toEqual({ "X-Custom": "v" }); + expect(result.chat_id).toBe("chat-1"); + } + }); + + it("passes account_id override through to validateAuthContext", async () => { + const request = createRequest( + { to: ["d@example.com"], subject: "s", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + { "x-api-key": "org-key" }, + ); + await validateSendEmailBody(request); + expect(mockValidateAuthContext).toHaveBeenCalledWith(expect.anything(), { + accountId: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + }); + + describe("validation errors (400)", () => { + it("rejects a missing 'to'", async () => { + const request = createRequest({ subject: "s" }, { "x-api-key": "k" }); + const result = await validateSendEmailBody(request); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("rejects an empty 'to' array", async () => { + const request = createRequest({ to: [], subject: "s" }, { "x-api-key": "k" }); + const result = await validateSendEmailBody(request); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("rejects a non-email recipient", async () => { + const request = createRequest({ to: ["not-an-email"], subject: "s" }, { "x-api-key": "k" }); + const result = await validateSendEmailBody(request); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("rejects a missing subject", async () => { + const request = createRequest({ to: ["d@example.com"] }, { "x-api-key": "k" }); + const result = await validateSendEmailBody(request); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + }); + + describe("auth", () => { + it("returns the auth NextResponse when validateAuthContext fails", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + const request = createRequest({ to: ["d@example.com"], subject: "s" }); + const result = await validateSendEmailBody(request); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(401); + }); + }); +}); diff --git a/lib/emails/assertRecipientsAllowed.ts b/lib/emails/assertRecipientsAllowed.ts new file mode 100644 index 000000000..d9f258600 --- /dev/null +++ b/lib/emails/assertRecipientsAllowed.ts @@ -0,0 +1,40 @@ +import { accountHasPaymentMethod } from "@/lib/stripe/accountHasPaymentMethod"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +export type RecipientCheckResult = { allowed: true } | { allowed: false; disallowed: string[] }; + +/** + * Enforces the POST /api/emails recipient restriction: without a payment method + * on file, an account may only send to its own email address(es). Once a card + * is on file (a prior subscription or credits top-up), any recipient is allowed. + * + * Matches the documented contract (docs#251): `to`/`cc` are "restricted to the + * account's own email unless a payment method is on file". + * + * @param accountId The authenticated account (validated UUID upstream). + * @param recipients The combined `to` + `cc` list to check. + * @returns `{ allowed: true }` or `{ allowed: false, disallowed }` listing the + * recipients that are not the account's own email. + */ +export async function assertRecipientsAllowed({ + accountId, + recipients, +}: { + accountId: string; + recipients: string[]; +}): Promise { + if (await accountHasPaymentMethod(accountId)) { + return { allowed: true }; + } + + const ownRows = await selectAccountEmails({ accountIds: accountId }); + const ownEmails = new Set(ownRows.map(row => row.email.toLowerCase())); + + const disallowed = recipients.filter(email => !ownEmails.has(email.toLowerCase())); + + if (disallowed.length > 0) { + return { allowed: false, disallowed }; + } + + return { allowed: true }; +} diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts new file mode 100644 index 000000000..006bd0028 --- /dev/null +++ b/lib/emails/sendEmailHandler.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateSendEmailBody } from "@/lib/emails/validateSendEmailBody"; +import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; + +/** + * Handler for POST /api/emails. + * + * Sends an email to the explicit recipients in the request body via Resend + * (from `Agent by Recoup `), reusing the same + * `processAndSendEmail` domain function as the `send_email` MCP tool. + * Account-scoped: requires authentication via x-api-key or Authorization Bearer. + * Body validation, auth, and the recipient restriction all live in + * `validateSendEmailBody`. + * + * @param request - The request object. + * @returns A NextResponse with the send result. + */ +export async function sendEmailHandler(request: NextRequest): Promise { + const validated = await validateSendEmailBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { to, cc = [], subject, text, html = "", headers = {}, chat_id } = validated; + + const result = await processAndSendEmail({ + to, + cc, + subject, + text, + html, + headers, + room_id: chat_id, + }); + + if (result.success === false) { + return NextResponse.json( + { status: "error", error: result.error }, + { status: 502, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { success: true, message: result.message, id: result.id }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/emails/validateSendEmailBody.ts b/lib/emails/validateSendEmailBody.ts new file mode 100644 index 000000000..5dbb8d4e3 --- /dev/null +++ b/lib/emails/validateSendEmailBody.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { assertRecipientsAllowed } from "@/lib/emails/assertRecipientsAllowed"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { z } from "zod"; + +export const sendEmailBodySchema = z.object({ + to: z + .array(z.string().email("each 'to' entry must be a valid email")) + .min(1, "to must include at least one recipient"), + cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(), + subject: z.string({ message: "subject is required" }).min(1, "subject cannot be empty"), + text: z.string().optional(), + html: z.string().default("").optional(), + headers: z.record(z.string(), z.string()).default({}).optional(), + chat_id: z.string().optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + +export type SendEmailBody = z.infer; + +export type ValidatedSendEmailRequest = SendEmailBody & { accountId: string }; + +/** + * Validates POST /api/emails: parses the body, authenticates via + * validateAuthContext (x-api-key or Bearer), then enforces the recipient + * restriction (without a payment method on file, `to`/`cc` are limited to the + * account's own email). Takes an explicit `to` recipient list. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation/auth/recipients fail, or the validated request data. + */ +export async function validateSendEmailBody( + request: NextRequest, +): Promise { + const body = await safeParseJson(request); + const result = sendEmailBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const authContext = await validateAuthContext(request, { + accountId: result.data.account_id, + }); + + if (authContext instanceof NextResponse) { + return authContext; + } + + const recipientCheck = await assertRecipientsAllowed({ + accountId: authContext.accountId, + recipients: [...result.data.to, ...(result.data.cc ?? [])], + }); + if (recipientCheck.allowed === false) { + return NextResponse.json( + { + status: "error", + error: `Without a payment method on file, emails can only be sent to the account's own address. Disallowed recipients: ${recipientCheck.disallowed.join(", ")}. Add a payment method to send to any recipient.`, + disallowed_recipients: recipientCheck.disallowed, + }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { + ...result.data, + accountId: authContext.accountId, + }; +} diff --git a/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts b/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts index 8654dd19e..619f1c56d 100644 --- a/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts +++ b/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { findStripeCustomerForAccount } from "@/lib/stripe/findStripeCustomerForAccount"; -import { findDefaultPaymentMethodForCustomer } from "@/lib/stripe/findDefaultPaymentMethodForCustomer"; +import { accountHasPaymentMethod } from "@/lib/stripe/accountHasPaymentMethod"; import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; @@ -18,9 +17,7 @@ import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; export async function ensureSongstatsPaymentMethod( accountId: string, ): Promise { - const customerId = await findStripeCustomerForAccount(accountId); - const paymentMethod = customerId ? await findDefaultPaymentMethodForCustomer(customerId) : null; - if (paymentMethod) return null; + if (await accountHasPaymentMethod(accountId)) return null; const session = await createCardOnFileSession( accountId, diff --git a/lib/stripe/accountHasPaymentMethod.ts b/lib/stripe/accountHasPaymentMethod.ts new file mode 100644 index 000000000..5d9d8abe9 --- /dev/null +++ b/lib/stripe/accountHasPaymentMethod.ts @@ -0,0 +1,21 @@ +import { findStripeCustomerForAccount } from "@/lib/stripe/findStripeCustomerForAccount"; +import { findDefaultPaymentMethodForCustomer } from "@/lib/stripe/findDefaultPaymentMethodForCustomer"; + +/** + * Read-only check for whether an account has a payment method (card) on file. + * + * Resolves the account's Stripe Customer without provisioning one + * (`findStripeCustomerForAccount`), then checks for a default/attached card + * (`findDefaultPaymentMethodForCustomer`). Returns false when no Customer + * exists yet or the Customer has no card. + * + * @param accountId Must be a validated UUID (interpolated into a Stripe + * customer-search query downstream). + */ +export async function accountHasPaymentMethod(accountId: string): Promise { + const customerId = await findStripeCustomerForAccount(accountId); + if (!customerId) return false; + + const paymentMethodId = await findDefaultPaymentMethodForCustomer(customerId); + return paymentMethodId !== null; +} From 6d9e74e8fe8a9c48c5c71683bb8012846ef30d5d Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 25 Jun 2026 16:10:13 -0500 Subject: [PATCH 18/27] fix(skills): install the renamed global skills into sandboxes (chat#1815) (#712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox agent never gets the recoup-api playbook, so scheduled "send an email" tasks complete with zero tool calls ("I don't have a tool to send emails"). Root cause: defaultGlobalSkillRefs installed `recoup-api` and `artist-workspace`, but both were renamed/split in recoupable/skills. The install runs `npx skills add recoupable/skills --skill recoup-api`, which throws on the unknown name (caught best-effort) → no platform skills land in the sandbox. Breaks all platform-skill loading, not just email. - defaultGlobalSkillRefs.ts: use the current slugs — recoup-platform-api-access, recoup-platform-build-workspace, recoup-roster-{add,list,manage}-artist (restores the old recoup-api + artist-workspace coverage, now split). - recoupApiSkillPrompt.ts: update the skill names the nudge tells the agent to load, and add send-email / deliver-report to the triggers so the agent loads recoup-platform-api-access for email tasks instead of claiming no tool. 569 tests green; tsc 0 new errors; lint clean. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/chat/recoupApiSkillPrompt.ts | 20 +++++++++++-------- .../__tests__/defaultGlobalSkillRefs.test.ts | 15 +++++++++++--- lib/skills/defaultGlobalSkillRefs.ts | 14 +++++++++++-- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/lib/chat/recoupApiSkillPrompt.ts b/lib/chat/recoupApiSkillPrompt.ts index 93f4d2e39..446c3cd54 100644 --- a/lib/chat/recoupApiSkillPrompt.ts +++ b/lib/chat/recoupApiSkillPrompt.ts @@ -1,11 +1,15 @@ /** - * Always-on nudge appended to the agent's system instructions. Points - * at the `recoup-api` and `artist-workspace` skills so prompts about - * anything owned by the user's Recoup account reliably load the right - * playbook — either the filesystem (for sandbox inventory and create- - * artist scaffolding) or the API (for live data) — instead of the - * agent guessing endpoint paths or interpreting overloaded nouns like - * "tasks" as generic repo TODOs. + * Always-on nudge appended to the agent's system instructions. Points at the + * `recoup-platform-api-access` and roster/workspace skills so prompts about + * anything owned by the user's Recoup account — including sending email — + * reliably load the right playbook (the filesystem for sandbox inventory and + * create-artist scaffolding, or the API for live data + actions) instead of the + * agent guessing endpoint paths, interpreting overloaded nouns like "tasks" as + * generic repo TODOs, or claiming it "has no tool" for something the API does. + * + * Skill slugs must match the current `recoupable/skills` names exactly — the + * legacy `recoup-api` + `artist-workspace` names were renamed/split + * (recoupable/chat#1815). */ export const recoupApiSkillPrompt = - 'If you\'re asked about anything belonging to their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, notifications, subscriptions, or any other resource visible at recoup-api.vercel.app / developers.recoupable.com — pick the right skill first instead of guessing. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `artist-workspace` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (the sandbox is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist", "set up a new artist") also load `artist-workspace` first — it scaffolds the artist\'s `RECOUP.md` as a checklist file you tick off step-by-step, which is what keeps the 8-step chain from dropping steps when run from a sandbox; the curl-by-curl reference for each step lives via `recoup-api` (developers.recoupable.com/workflows/create-artist), but the checklist file is the source of truth for what\'s done. For live data (socials, posts, metrics, research, tasks, notifications) or anything not in the tree, load `recoup-api` — and when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID` on the CLI) so you get results for the sandbox\'s org, not every org the user belongs to. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.'; + 'If you\'re asked to do anything involving their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, subscriptions, **sending an email or delivering a report**, or any other resource/action at recoup-api.vercel.app / developers.recoupable.com — load the right skill first instead of guessing or assuming you lack a tool. For live data or actions against the API (socials, posts, metrics, research, tasks, and **sending email via `POST /api/emails`** — e.g. "email X to Y", scheduled-report output) load `recoup-platform-api-access`; when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID`) so you get the sandbox\'s org, not every org the user belongs to. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `recoup-roster-list-artists` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (it is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist") load `recoup-roster-add-artist`; to operate inside one artist\'s folder load `recoup-roster-manage-artist`; to scaffold the folder tree load `recoup-platform-build-workspace`. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.'; diff --git a/lib/skills/__tests__/defaultGlobalSkillRefs.test.ts b/lib/skills/__tests__/defaultGlobalSkillRefs.test.ts index 52815c166..a4b5371a0 100644 --- a/lib/skills/__tests__/defaultGlobalSkillRefs.test.ts +++ b/lib/skills/__tests__/defaultGlobalSkillRefs.test.ts @@ -2,10 +2,19 @@ import { describe, it, expect } from "vitest"; import { DEFAULT_GLOBAL_SKILL_REFS } from "@/lib/skills/defaultGlobalSkillRefs"; describe("DEFAULT_GLOBAL_SKILL_REFS", () => { - it("ships recoup-api and artist-workspace as platform defaults", () => { + it("ships the current recoupable/skills slugs (post-rename) as platform defaults", () => { const sourceNames = DEFAULT_GLOBAL_SKILL_REFS.map(r => `${r.source}::${r.skillName}`); - expect(sourceNames).toContain("recoupable/skills::recoup-api"); - expect(sourceNames).toContain("recoupable/skills::artist-workspace"); + expect(sourceNames).toContain("recoupable/skills::recoup-platform-api-access"); + expect(sourceNames).toContain("recoupable/skills::recoup-platform-build-workspace"); + expect(sourceNames).toContain("recoupable/skills::recoup-roster-add-artist"); + expect(sourceNames).toContain("recoupable/skills::recoup-roster-list-artists"); + expect(sourceNames).toContain("recoupable/skills::recoup-roster-manage-artist"); + }); + + it("does not reference the legacy pre-rename names", () => { + const names = DEFAULT_GLOBAL_SKILL_REFS.map(r => r.skillName); + expect(names).not.toContain("recoup-api"); + expect(names).not.toContain("artist-workspace"); }); it("only references the recoupable/skills source", () => { diff --git a/lib/skills/defaultGlobalSkillRefs.ts b/lib/skills/defaultGlobalSkillRefs.ts index 1d2984b95..f8adc8bd9 100644 --- a/lib/skills/defaultGlobalSkillRefs.ts +++ b/lib/skills/defaultGlobalSkillRefs.ts @@ -5,8 +5,18 @@ import type { GlobalSkillRef } from "@/lib/skills/globalSkillRef"; * of user preferences. Platform-level defaults that the agent should * always be able to load on demand — they are descriptor-only at install * time, so adding entries here doesn't bloat the system prompt. + * + * Names must match the current `recoupable/skills` slugs exactly — + * `installGlobalSkills` runs `npx skills add … --skill `, which + * throws on an unknown name (recoupable/chat#1815). The legacy `recoup-api` + * + `artist-workspace` skills were renamed/split into the entries below. */ export const DEFAULT_GLOBAL_SKILL_REFS: readonly GlobalSkillRef[] = [ - { source: "recoupable/skills", skillName: "recoup-api" }, - { source: "recoupable/skills", skillName: "artist-workspace" }, + // API access + live data + send-email (was `recoup-api`). + { source: "recoupable/skills", skillName: "recoup-platform-api-access" }, + // Folder-tree / RECOUP.md scaffolding + roster ops (was `artist-workspace`). + { source: "recoupable/skills", skillName: "recoup-platform-build-workspace" }, + { source: "recoupable/skills", skillName: "recoup-roster-add-artist" }, + { source: "recoupable/skills", skillName: "recoup-roster-list-artists" }, + { source: "recoupable/skills", skillName: "recoup-roster-manage-artist" }, ]; From 18d20f7c1005dc239bbc7cac217cdcf33ea9ba6f Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 25 Jun 2026 17:13:08 -0500 Subject: [PATCH 19/27] feat(emails): make `to` and `subject` optional on POST /api/emails (#710) * feat: make `to` optional on POST /api/emails (default to account's own email) When `to` is omitted, resolve the authenticated account's own email(s) via account_emails and use them as recipients, so a caller can "email me this" without restating their address (the common scheduled-report case). `to` stays minItems:1 when provided. The recipient restriction is unchanged and runs on the resolved recipients (own email always allowed). 400 when `to` is omitted and the account has no email on file. Implements the merged contract docs#252. Part of chat#1815. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(emails): make subject optional, default from body (docs#252) Follows the merged docs#252 contract (subject dropped from required). Resend requires a non-empty subject, so resolve one server-side when the caller omits it: new resolveEmailSubject() returns the provided subject, else the body's first heading/line (text preferred, then HTML with tags stripped), else "Message from Recoup". validateSendEmailBody now returns a always-string subject; schema marks it optional. Tests: resolveEmailSubject unit (provided/derived/html/fallback/cap), validator subject-defaulting cases; removed the now-obsolete "rejects a missing subject" 400 test. 155 emails/notifications tests green; tsc 0 new errors; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(emails): extract firstMeaningfulLine + stripHtml to own files (SRP) Per review: one exported function per file. Move the two pure string helpers out of resolveEmailSubject.ts into lib/emails/firstMeaningfulLine.ts and lib/emails/stripHtml.ts, each with its own unit test. resolveEmailSubject now imports them. Behavior unchanged; 14 tests green, tsc/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/firstMeaningfulLine.test.ts | 19 +++++ .../__tests__/resolveEmailSubject.test.ts | 39 ++++++++++ lib/emails/__tests__/stripHtml.test.ts | 21 +++++ .../__tests__/validateSendEmailBody.test.ts | 78 +++++++++++++++++-- lib/emails/firstMeaningfulLine.ts | 14 ++++ lib/emails/resolveEmailSubject.ts | 33 ++++++++ lib/emails/stripHtml.ts | 15 ++++ lib/emails/validateSendEmailBody.ts | 50 ++++++++++-- 8 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 lib/emails/__tests__/firstMeaningfulLine.test.ts create mode 100644 lib/emails/__tests__/resolveEmailSubject.test.ts create mode 100644 lib/emails/__tests__/stripHtml.test.ts create mode 100644 lib/emails/firstMeaningfulLine.ts create mode 100644 lib/emails/resolveEmailSubject.ts create mode 100644 lib/emails/stripHtml.ts diff --git a/lib/emails/__tests__/firstMeaningfulLine.test.ts b/lib/emails/__tests__/firstMeaningfulLine.test.ts new file mode 100644 index 000000000..7fc5763ac --- /dev/null +++ b/lib/emails/__tests__/firstMeaningfulLine.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { firstMeaningfulLine } from "../firstMeaningfulLine"; + +describe("firstMeaningfulLine", () => { + it("returns the first non-empty line", () => { + expect(firstMeaningfulLine("\n\nHello there\nmore")).toBe("Hello there"); + }); + + it("strips a leading Markdown heading", () => { + expect(firstMeaningfulLine("# Pulse Report\n\nbody")).toBe("Pulse Report"); + expect(firstMeaningfulLine("### Deep heading")).toBe("Deep heading"); + }); + + it("returns empty string for empty/undefined/whitespace input", () => { + expect(firstMeaningfulLine()).toBe(""); + expect(firstMeaningfulLine("")).toBe(""); + expect(firstMeaningfulLine(" \n \n")).toBe(""); + }); +}); diff --git a/lib/emails/__tests__/resolveEmailSubject.test.ts b/lib/emails/__tests__/resolveEmailSubject.test.ts new file mode 100644 index 000000000..fd15999d8 --- /dev/null +++ b/lib/emails/__tests__/resolveEmailSubject.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { resolveEmailSubject, DEFAULT_EMAIL_SUBJECT } from "../resolveEmailSubject"; + +describe("resolveEmailSubject", () => { + it("returns the provided subject when non-empty", () => { + expect(resolveEmailSubject({ subject: "Weekly report", text: "# Pulse" })).toBe( + "Weekly report", + ); + }); + + it("trims the provided subject", () => { + expect(resolveEmailSubject({ subject: " Hi " })).toBe("Hi"); + }); + + it("derives from the text body's first heading (stripping leading #)", () => { + expect(resolveEmailSubject({ text: "# Pulse Report\n\nbody here" })).toBe("Pulse Report"); + }); + + it("derives from the first non-empty line of plain text", () => { + expect(resolveEmailSubject({ subject: "", text: "\n\nHello there\nmore" })).toBe("Hello there"); + }); + + it("derives from html when no text (tags stripped)", () => { + expect(resolveEmailSubject({ html: "

Launch day

details

" })).toBe("Launch day"); + }); + + it("falls back to the default when the body is empty", () => { + expect(resolveEmailSubject({})).toBe(DEFAULT_EMAIL_SUBJECT); + expect(resolveEmailSubject({ subject: " ", text: " ", html: "" })).toBe( + DEFAULT_EMAIL_SUBJECT, + ); + }); + + it("caps a very long derived subject", () => { + const long = "x".repeat(300); + const result = resolveEmailSubject({ text: long }); + expect(result.length).toBeLessThanOrEqual(120); + }); +}); diff --git a/lib/emails/__tests__/stripHtml.test.ts b/lib/emails/__tests__/stripHtml.test.ts new file mode 100644 index 000000000..cd67f58da --- /dev/null +++ b/lib/emails/__tests__/stripHtml.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { stripHtml } from "../stripHtml"; + +describe("stripHtml", () => { + it("strips tags and breaks blocks onto their own lines", () => { + expect(stripHtml("

Launch day

details

")).toBe("Launch day\n details"); + }); + + it("turns
into a newline", () => { + expect(stripHtml("line one
line two")).toBe("line one\nline two"); + }); + + it("collapses runs of spaces/tabs and trims", () => { + expect(stripHtml(" a\t\t b ")).toBe("a b"); + }); + + it("returns empty string for empty/undefined input", () => { + expect(stripHtml()).toBe(""); + expect(stripHtml("")).toBe(""); + }); +}); diff --git a/lib/emails/__tests__/validateSendEmailBody.test.ts b/lib/emails/__tests__/validateSendEmailBody.test.ts index b4e315b77..a53a2c56e 100644 --- a/lib/emails/__tests__/validateSendEmailBody.test.ts +++ b/lib/emails/__tests__/validateSendEmailBody.test.ts @@ -4,6 +4,7 @@ import { validateSendEmailBody } from "../validateSendEmailBody"; const mockValidateAuthContext = vi.fn(); const mockAssertRecipientsAllowed = vi.fn(); +const mockSelectAccountEmails = vi.fn(); vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), @@ -13,6 +14,10 @@ vi.mock("@/lib/emails/assertRecipientsAllowed", () => ({ assertRecipientsAllowed: (...args: unknown[]) => mockAssertRecipientsAllowed(...args), })); +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: (...args: unknown[]) => mockSelectAccountEmails(...args), +})); + vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); @@ -38,6 +43,7 @@ describe("validateSendEmailBody", () => { authToken: "test-api-key", }); mockAssertRecipientsAllowed.mockResolvedValue({ allowed: true }); + mockSelectAccountEmails.mockResolvedValue([{ email: "owner@example.com" }]); }); describe("recipient restriction", () => { @@ -74,6 +80,29 @@ describe("validateSendEmailBody", () => { }); }); + describe("subject defaulting", () => { + it("defaults a missing subject to the body's first heading", async () => { + const request = createRequest( + { to: ["d@example.com"], text: "# Pulse Report\n\nbody" }, + { "x-api-key": "k" }, + ); + const result = await validateSendEmailBody(request); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.subject).toBe("Pulse Report"); + } + }); + + it("defaults to `Message from Recoup` when subject and body are empty", async () => { + const request = createRequest({ to: ["d@example.com"] }, { "x-api-key": "k" }); + const result = await validateSendEmailBody(request); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.subject).toBe("Message from Recoup"); + } + }); + }); + describe("successful validation", () => { it("returns validated data with to + subject + text", async () => { const request = createRequest( @@ -128,14 +157,52 @@ describe("validateSendEmailBody", () => { }); }); - describe("validation errors (400)", () => { - it("rejects a missing 'to'", async () => { + describe("default recipient (omitted 'to')", () => { + it("defaults 'to' to the account's own email when omitted", async () => { + mockSelectAccountEmails.mockResolvedValue([{ email: "owner@example.com" }]); + const request = createRequest({ subject: "s", text: "hi" }, { "x-api-key": "k" }); + const result = await validateSendEmailBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.to).toEqual(["owner@example.com"]); + } + expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: "account-123" }); + }); + + it("includes every account email when the account has more than one", async () => { + mockSelectAccountEmails.mockResolvedValue([ + { email: "owner@example.com" }, + { email: "owner.alt@example.com" }, + ]); + const request = createRequest({ subject: "s" }, { "x-api-key": "k" }); + const result = await validateSendEmailBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.to).toEqual(["owner@example.com", "owner.alt@example.com"]); + } + }); + + it("returns 400 when 'to' is omitted and the account has no email on file", async () => { + mockSelectAccountEmails.mockResolvedValue([]); const request = createRequest({ subject: "s" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); expect(result).toBeInstanceOf(NextResponse); if (result instanceof NextResponse) expect(result.status).toBe(400); }); + it("does not resolve account emails when 'to' is provided", async () => { + const request = createRequest( + { to: ["dest@example.com"], subject: "s" }, + { "x-api-key": "k" }, + ); + await validateSendEmailBody(request); + expect(mockSelectAccountEmails).not.toHaveBeenCalled(); + }); + }); + + describe("validation errors (400)", () => { it("rejects an empty 'to' array", async () => { const request = createRequest({ to: [], subject: "s" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); @@ -150,12 +217,7 @@ describe("validateSendEmailBody", () => { if (result instanceof NextResponse) expect(result.status).toBe(400); }); - it("rejects a missing subject", async () => { - const request = createRequest({ to: ["d@example.com"] }, { "x-api-key": "k" }); - const result = await validateSendEmailBody(request); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) expect(result.status).toBe(400); - }); + // subject is now optional (defaults from the body) — covered by "subject defaulting" above. }); describe("auth", () => { diff --git a/lib/emails/firstMeaningfulLine.ts b/lib/emails/firstMeaningfulLine.ts new file mode 100644 index 000000000..643f6594c --- /dev/null +++ b/lib/emails/firstMeaningfulLine.ts @@ -0,0 +1,14 @@ +/** + * First non-empty line of a string, with any leading Markdown heading `#`s + * stripped. Returns "" when there is no meaningful line. + * + * @param value - The string to scan (e.g. a Markdown/plain-text email body). + */ +export function firstMeaningfulLine(value?: string): string { + if (!value) return ""; + for (const line of value.split("\n")) { + const cleaned = line.replace(/^\s*#+\s*/, "").trim(); + if (cleaned) return cleaned; + } + return ""; +} diff --git a/lib/emails/resolveEmailSubject.ts b/lib/emails/resolveEmailSubject.ts new file mode 100644 index 000000000..2bc335318 --- /dev/null +++ b/lib/emails/resolveEmailSubject.ts @@ -0,0 +1,33 @@ +import { firstMeaningfulLine } from "@/lib/emails/firstMeaningfulLine"; +import { stripHtml } from "@/lib/emails/stripHtml"; + +/** Fallback subject when none is provided and the body has no usable first line. */ +export const DEFAULT_EMAIL_SUBJECT = "Message from Recoup"; + +/** Max length for a subject derived from the body (full provided subjects pass through). */ +const MAX_DERIVED_SUBJECT_LENGTH = 120; + +/** + * Resolve the subject for an outbound email. Resend requires a non-empty + * subject, but `POST /api/emails` lets the caller omit it (recoupable/chat#1815): + * use the provided subject when present, else derive one from the body's first + * heading/line (text preferred, then HTML), else fall back to a generic default. + * Mirrors the documented contract in docs#252. + */ +export function resolveEmailSubject({ + subject, + text, + html, +}: { + subject?: string; + text?: string; + html?: string; +}): string { + const provided = subject?.trim(); + if (provided) return provided; + + const derived = firstMeaningfulLine(text) || firstMeaningfulLine(stripHtml(html)); + if (derived) return derived.slice(0, MAX_DERIVED_SUBJECT_LENGTH); + + return DEFAULT_EMAIL_SUBJECT; +} diff --git a/lib/emails/stripHtml.ts b/lib/emails/stripHtml.ts new file mode 100644 index 000000000..d18f66a3a --- /dev/null +++ b/lib/emails/stripHtml.ts @@ -0,0 +1,15 @@ +/** + * Strip HTML tags to plain text, breaking at block boundaries so the first + * block stays on its own line (so callers can pull the first line/heading). + * + * @param html - The HTML string to flatten. + */ +export function stripHtml(html?: string): string { + if (!html) return ""; + return html + .replace(/<\/(h[1-6]|p|div|li|tr)\s*>/gi, "\n") + .replace(//gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/[ \t]+/g, " ") + .trim(); +} diff --git a/lib/emails/validateSendEmailBody.ts b/lib/emails/validateSendEmailBody.ts index 5dbb8d4e3..041437dac 100644 --- a/lib/emails/validateSendEmailBody.ts +++ b/lib/emails/validateSendEmailBody.ts @@ -2,15 +2,18 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { assertRecipientsAllowed } from "@/lib/emails/assertRecipientsAllowed"; +import { resolveEmailSubject } from "@/lib/emails/resolveEmailSubject"; import { safeParseJson } from "@/lib/networking/safeParseJson"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { z } from "zod"; export const sendEmailBodySchema = z.object({ to: z .array(z.string().email("each 'to' entry must be a valid email")) - .min(1, "to must include at least one recipient"), + .min(1, "to must include at least one recipient") + .optional(), cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(), - subject: z.string({ message: "subject is required" }).min(1, "subject cannot be empty"), + subject: z.string().optional(), text: z.string().optional(), html: z.string().default("").optional(), headers: z.record(z.string(), z.string()).default({}).optional(), @@ -20,13 +23,23 @@ export const sendEmailBodySchema = z.object({ export type SendEmailBody = z.infer; -export type ValidatedSendEmailRequest = SendEmailBody & { accountId: string }; +export type ValidatedSendEmailRequest = Omit & { + to: string[]; + subject: string; + accountId: string; +}; /** * Validates POST /api/emails: parses the body, authenticates via - * validateAuthContext (x-api-key or Bearer), then enforces the recipient - * restriction (without a payment method on file, `to`/`cc` are limited to the - * account's own email). Takes an explicit `to` recipient list. + * validateAuthContext (x-api-key or Bearer), resolves the recipients, then + * enforces the recipient restriction (without a payment method on file, + * `to`/`cc` are limited to the account's own email). + * + * `to` is optional: when omitted, the email defaults to the authenticated + * account's own email address(es) (via `account_emails`), so a caller can + * "email me this" without restating their address. `subject` is optional too: + * when omitted it defaults to the body's first heading/line, else + * `Message from Recoup`. Returns the resolved `to` + `subject`. * * @param request - The NextRequest object * @returns A NextResponse with an error if validation/auth/recipients fail, or the validated request data. @@ -60,9 +73,24 @@ export async function validateSendEmailBody( return authContext; } + let to = result.data.to ?? []; + if (to.length === 0) { + const ownRows = await selectAccountEmails({ accountIds: authContext.accountId }); + to = ownRows.map(row => row.email); + if (to.length === 0) { + return NextResponse.json( + { + status: "error", + error: "No email address found for the authenticated account.", + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + } + const recipientCheck = await assertRecipientsAllowed({ accountId: authContext.accountId, - recipients: [...result.data.to, ...(result.data.cc ?? [])], + recipients: [...to, ...(result.data.cc ?? [])], }); if (recipientCheck.allowed === false) { return NextResponse.json( @@ -75,8 +103,16 @@ export async function validateSendEmailBody( ); } + const subject = resolveEmailSubject({ + subject: result.data.subject, + text: result.data.text, + html: result.data.html, + }); + return { ...result.data, + to, + subject, accountId: authContext.accountId, }; } From d9815368f327f0a568f9b12d8d1d22ab69b2b6d3 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 25 Jun 2026 17:42:44 -0500 Subject: [PATCH 20/27] chore: remove POST /api/notifications (superseded by /api/emails) (#711) /api/notifications emailed only the account's own address. With `to` now optional on POST /api/emails (defaulting to the account's own email, api#710), /api/emails fully subsumes it, so we standardize on /api/emails and delete the duplicate route. Deletes app/api/notifications/route.ts and lib/notifications/* (handler, validator, tests). Keeps processAndSendEmail (the shared domain fn for the send_email MCP tool) and updates its stale JSDoc to reference /api/emails. Implements docs#253. Part of chat#1815 cleanup. grep for api/notifications / createNotification / lib/notifications is clean; emails suite green. Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/notifications/route.ts | 42 ---- lib/emails/processAndSendEmail.ts | 2 +- .../createNotificationHandler.test.ts | 194 ---------------- .../validateCreateNotificationBody.test.ts | 212 ------------------ .../createNotificationHandler.ts | 76 ------- .../validateCreateNotificationBody.ts | 74 ------ 6 files changed, 1 insertion(+), 599 deletions(-) delete mode 100644 app/api/notifications/route.ts delete mode 100644 lib/notifications/__tests__/createNotificationHandler.test.ts delete mode 100644 lib/notifications/__tests__/validateCreateNotificationBody.test.ts delete mode 100644 lib/notifications/createNotificationHandler.ts delete mode 100644 lib/notifications/validateCreateNotificationBody.ts diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts deleted file mode 100644 index 22597e8f0..000000000 --- a/app/api/notifications/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { createNotificationHandler } from "@/lib/notifications/createNotificationHandler"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/notifications - * - * Sends a notification email to the authenticated account's email address via Resend. - * The recipient is automatically resolved from the API key or Bearer token. - * Requires authentication via x-api-key header or Authorization bearer token. - * - * Body parameters: - * - subject (required): email subject line - * - text (optional): plain text / Markdown body - * - html (optional): raw HTML body (takes precedence over text) - * - cc (optional): array of CC email addresses - * - headers (optional): custom email headers - * - room_id (optional): room ID for chat link in footer - * - account_id (optional): UUID of the account to send to (org keys only) - * - * @param request - The request object. - * @returns A NextResponse with send result. - */ -export async function POST(request: NextRequest): Promise { - return createNotificationHandler(request); -} - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; -export const revalidate = 0; diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts index 934939ca6..16402cd5b 100644 --- a/lib/emails/processAndSendEmail.ts +++ b/lib/emails/processAndSendEmail.ts @@ -30,7 +30,7 @@ export type ProcessAndSendEmailResult = ProcessAndSendEmailSuccess | ProcessAndS /** * Shared email processing and sending logic used by both the - * POST /api/notifications handler and the send_email MCP tool. + * POST /api/emails handler and the send_email MCP tool. * * Handles room lookup, footer generation, markdown-to-HTML conversion, * and the Resend API call. diff --git a/lib/notifications/__tests__/createNotificationHandler.test.ts b/lib/notifications/__tests__/createNotificationHandler.test.ts deleted file mode 100644 index ca7fb677f..000000000 --- a/lib/notifications/__tests__/createNotificationHandler.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { createNotificationHandler } from "../createNotificationHandler"; - -const mockValidateAuthContext = vi.fn(); -const mockSelectAccountEmails = vi.fn(); -const mockProcessAndSendEmail = vi.fn(); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), -})); - -vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ - default: (...args: unknown[]) => mockSelectAccountEmails(...args), -})); - -vi.mock("@/lib/emails/processAndSendEmail", () => ({ - processAndSendEmail: (...args: unknown[]) => mockProcessAndSendEmail(...args), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/networking/safeParseJson", () => ({ - safeParseJson: vi.fn(async (req: Request) => req.json()), -})); - -function createRequest(body: unknown): NextRequest { - return new NextRequest("https://recoup-api.vercel.app/api/notifications", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": "test-key", - }, - body: JSON.stringify(body), - }); -} - -describe("createNotificationHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "test-key", - }); - mockSelectAccountEmails.mockResolvedValue([ - { id: "email-1", account_id: "account-123", email: "owner@example.com", updated_at: "" }, - ]); - }); - - it("returns 401 when authentication fails", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - - const request = createRequest({ subject: "Test" }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(401); - }); - - it("returns 400 when body validation fails", async () => { - const request = createRequest({}); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.status).toBe("error"); - }); - - it("returns 400 when account has no email", async () => { - mockSelectAccountEmails.mockResolvedValue([]); - - const request = createRequest({ subject: "Test", text: "Hello" }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toContain("No email address found"); - }); - - it("sends email to account owner with text body", async () => { - mockProcessAndSendEmail.mockResolvedValue({ - success: true, - message: - "Email sent successfully from Agent by Recoup to owner@example.com. CC: none.", - id: "email-123", - }); - - const request = createRequest({ - subject: "Test Subject", - text: "Hello world", - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(200); - const data = await response.json(); - expect(data.success).toBe(true); - expect(data.id).toBe("email-123"); - expect(data.message).toContain("owner@example.com"); - expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: "account-123" }); - expect(mockProcessAndSendEmail).toHaveBeenCalledWith({ - to: ["owner@example.com"], - cc: [], - subject: "Test Subject", - text: "Hello world", - html: "", - headers: {}, - room_id: undefined, - }); - }); - - it("passes CC and room_id through to processAndSendEmail", async () => { - mockProcessAndSendEmail.mockResolvedValue({ - success: true, - message: "Email sent successfully.", - id: "email-789", - }); - - const request = createRequest({ - cc: ["cc@example.com"], - subject: "Test", - text: "Hello", - room_id: "room-abc", - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(200); - expect(mockProcessAndSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - cc: ["cc@example.com"], - room_id: "room-abc", - }), - ); - }); - - it("returns 502 when email delivery fails", async () => { - mockProcessAndSendEmail.mockResolvedValue({ - success: false, - error: "Rate limited", - }); - - const request = createRequest({ - subject: "Test", - text: "Hello", - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(502); - const data = await response.json(); - expect(data.status).toBe("error"); - expect(data.error).toContain("Rate limited"); - }); - - it("resolves email from account_id override", async () => { - const overrideAccountId = "550e8400-e29b-41d4-a716-446655440000"; - - mockValidateAuthContext.mockResolvedValue({ - accountId: overrideAccountId, - orgId: "org-id", - authToken: "test-key", - }); - mockSelectAccountEmails.mockResolvedValue([ - { id: "email-2", account_id: overrideAccountId, email: "member@example.com", updated_at: "" }, - ]); - mockProcessAndSendEmail.mockResolvedValue({ - success: true, - message: "Email sent successfully to member@example.com.", - id: "email-override", - }); - - const request = createRequest({ - subject: "Override Test", - text: "Hello member", - account_id: overrideAccountId, - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(200); - expect(mockValidateAuthContext).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ accountId: overrideAccountId }), - ); - expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: overrideAccountId }); - expect(mockProcessAndSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: ["member@example.com"], - subject: "Override Test", - }), - ); - }); -}); diff --git a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts deleted file mode 100644 index 10390b15d..000000000 --- a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { validateCreateNotificationBody } from "../validateCreateNotificationBody"; - -const mockValidateAuthContext = vi.fn(); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/networking/safeParseJson", () => ({ - safeParseJson: vi.fn(async (req: Request) => req.json()), -})); - -function createRequest(body: unknown, headers: Record = {}): NextRequest { - const defaultHeaders: Record = { "Content-Type": "application/json" }; - return new NextRequest("http://localhost/api/notifications", { - method: "POST", - headers: { ...defaultHeaders, ...headers }, - body: JSON.stringify(body), - }); -} - -describe("validateCreateNotificationBody", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "test-api-key", - }); - }); - - describe("successful validation", () => { - it("returns validated data with subject and text", async () => { - const request = createRequest( - { subject: "Test Subject", text: "Hello world" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.subject).toBe("Test Subject"); - expect(result.text).toBe("Hello world"); - expect(result.accountId).toBe("account-123"); - } - }); - - it("returns validated data with all optional fields", async () => { - const request = createRequest( - { - cc: ["cc@example.com"], - subject: "Test Subject", - text: "Hello", - html: "

Hello

", - headers: { "X-Custom": "value" }, - room_id: "room-123", - }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.cc).toEqual(["cc@example.com"]); - expect(result.subject).toBe("Test Subject"); - expect(result.text).toBe("Hello"); - expect(result.html).toBe("

Hello

"); - expect(result.headers).toEqual({ "X-Custom": "value" }); - expect(result.room_id).toBe("room-123"); - expect(result.accountId).toBe("account-123"); - } - }); - - it("accepts subject-only body", async () => { - const request = createRequest({ subject: "Test Subject" }, { "x-api-key": "test-api-key" }); - const result = await validateCreateNotificationBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.subject).toBe("Test Subject"); - expect(result.accountId).toBe("account-123"); - } - }); - - it("uses account_id override for org API keys", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "550e8400-e29b-41d4-a716-446655440000", - orgId: "org-id", - authToken: "test-api-key", - }); - - const request = createRequest( - { subject: "Test", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(mockValidateAuthContext).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - accountId: "550e8400-e29b-41d4-a716-446655440000", - }), - ); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); - } - }); - - it("passes undefined accountId to auth when account_id is omitted", async () => { - const request = createRequest({ subject: "Test" }, { "x-api-key": "test-api-key" }); - await validateCreateNotificationBody(request); - - expect(mockValidateAuthContext).toHaveBeenCalledWith(expect.anything(), { - accountId: undefined, - }); - }); - }); - - describe("schema validation errors", () => { - it("returns 400 when subject is missing", async () => { - const request = createRequest({ text: "Hello" }, { "x-api-key": "test-api-key" }); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when subject is empty", async () => { - const request = createRequest({ subject: "" }, { "x-api-key": "test-api-key" }); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when cc contains invalid email", async () => { - const request = createRequest( - { subject: "Test", cc: ["not-valid"] }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when account_id is not a valid UUID", async () => { - const request = createRequest( - { subject: "Test", account_id: "invalid-uuid" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - }); - - describe("auth errors", () => { - it("returns 401 when auth is missing", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - - const request = createRequest({ subject: "Test" }); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - } - }); - - it("returns 403 when org API key lacks access to account_id", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403 }, - ), - ); - - const request = createRequest( - { subject: "Test", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(403); - const data = await result.json(); - expect(data.error).toBe("Access denied to specified account_id"); - } - }); - }); -}); diff --git a/lib/notifications/createNotificationHandler.ts b/lib/notifications/createNotificationHandler.ts deleted file mode 100644 index 35914a332..000000000 --- a/lib/notifications/createNotificationHandler.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateCreateNotificationBody } from "./validateCreateNotificationBody"; -import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -/** - * Handler for POST /api/notifications. - * Sends a notification email to the authenticated account's email address. - * The recipient is automatically resolved from the API key or Bearer token. - * Supports optional account_id override for org API keys. - * Requires authentication via x-api-key header or Authorization bearer token. - * - * @param request - The request object. - * @returns A NextResponse with the send result. - */ -export async function createNotificationHandler(request: NextRequest): Promise { - const validated = await validateCreateNotificationBody(request); - if (validated instanceof NextResponse) { - return validated; - } - - const { cc = [], subject, text, html = "", headers = {}, room_id, accountId } = validated; - - // Resolve recipient email from authenticated account - const accountEmails = await selectAccountEmails({ accountIds: accountId }); - const recipientEmail = accountEmails?.[0]?.email; - - if (!recipientEmail) { - return NextResponse.json( - { - status: "error", - error: "No email address found for the authenticated account.", - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - const result = await processAndSendEmail({ - to: [recipientEmail], - cc, - subject, - text, - html, - headers, - room_id, - }); - - if (result.success === false) { - return NextResponse.json( - { - status: "error", - error: result.error, - }, - { - status: 502, - headers: getCorsHeaders(), - }, - ); - } - - return NextResponse.json( - { - success: true, - message: result.message, - id: result.id, - }, - { - status: 200, - headers: getCorsHeaders(), - }, - ); -} diff --git a/lib/notifications/validateCreateNotificationBody.ts b/lib/notifications/validateCreateNotificationBody.ts deleted file mode 100644 index 33e3b6fad..000000000 --- a/lib/notifications/validateCreateNotificationBody.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { z } from "zod"; - -export const createNotificationBodySchema = z.object({ - cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(), - subject: z.string({ message: "subject is required" }).min(1, "subject cannot be empty"), - text: z.string().optional(), - html: z.string().default("").optional(), - headers: z.record(z.string(), z.string()).default({}).optional(), - room_id: z.string().optional(), - account_id: z.string().uuid("account_id must be a valid UUID").optional(), -}); - -export type CreateNotificationBody = z.infer; - -export type ValidatedCreateNotificationRequest = { - cc?: string[]; - subject: string; - text?: string; - html?: string; - headers?: Record; - room_id?: string; - accountId: string; -}; - -/** - * Validates POST /api/notifications request including auth headers, body parsing, - * schema validation, and account access authorization. - * - * @param request - The NextRequest object - * @returns A NextResponse with an error if validation fails, or the validated request data. - */ -export async function validateCreateNotificationBody( - request: NextRequest, -): Promise { - const body = await safeParseJson(request); - const result = createNotificationBodySchema.safeParse(body); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - const authContext = await validateAuthContext(request, { - accountId: result.data.account_id, - }); - - if (authContext instanceof NextResponse) { - return authContext; - } - - return { - cc: result.data.cc, - subject: result.data.subject, - text: result.data.text, - html: result.data.html, - headers: result.data.headers, - room_id: result.data.room_id, - accountId: authContext.accountId, - }; -} From be2abccb92cceebc8a4051b49b3779c440051d78 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 29 Jun 2026 18:40:41 -0500 Subject: [PATCH 21/27] =?UTF-8?q?fix:=20repoint=20dead=20.com=20hosts=20to?= =?UTF-8?q?=20live=20.dev=20(recoupable/chat#1819=20=C2=A7A+=C2=A7B)=20(#7?= =?UTF-8?q?19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Test (#715) * feat(measurement-jobs): free-tier card gate (setup mode) + instant backfill drain (#671) Two chat#1796 refinements on the historical (Songstats) path: 1. Free-tier card-on-file link. The gate was issuing the paid subscription checkout ($99/mo after a 30-day trial). New createCardOnFileSession uses Stripe Checkout `mode: "setup"` — collects a card for $0, no subscription, no Stripe product. The account then pays only for metered usage via credits. 2. Instant drain. After enqueuing a historical job, fire-and-forget start(songstatsBackfillWorkflow) so the backfill begins immediately instead of waiting up to 24h for the cron. Safe by reuse: the workflow's budget gate (limit − reserve − rolling-30d ledger) caps it to the Songstats quota and SKIP LOCKED prevents double-claiming with the daily cron, which stays as the backstop. Only kicks when something was actually enqueued. 26 new/updated unit tests; research+stripe+workflows suite 453 green; tsc/lint/format clean. * fix(songstats-backfill): backoff on 429 + defer instead of churn (chat#1797) (#673) Pacing/backoff + per-step logging for the Songstats backfill drain (chat#1797 bullets 1 & 3). Bounded exponential backoff (fetchSongstatsWithBackoff, 502/503/504/408/429), defer-to-pending past the bound with claimed-batch release, per-step + per-batch logging. * refactor(songstats): remove local quota ledger + budget gate (chat#1797) (#674) Bullet 2 of chat#1797 (code half). Songstats is the rate authority — removes getBackfillBudgetStep, the budget gate, and insertSongstatsQuotaLedger/selectSongstatsQuotaSpent. The drain now claims+processes regardless of the ledger (un-stalls the backfill); the songstats_quota_ledger table is dropped in recoupable/database#35 (apply AFTER this deploys). * feat: POST /api/catalogs (create + materialize from valuation snapshot) (#677) * feat: POST /api/catalogs create + materialize from valuation snapshot Creates a catalog owned by the authenticated account (account derived from credentials via validateAuthContext, never the body). With from.snapshot_id, materializes the catalog from a completed valuation snapshot: creates the catalogs row, links account_catalogs, adds the snapshot's measured ISRCs as catalog_songs, and records the catalog on the snapshot. Re-claiming the same snapshot is idempotent. TDD: validateCreateCatalogBody (6 tests) + createCatalogHandler (8 tests), red->green. New supabase wrappers: insertCatalog, selectCatalogById, insertAccountCatalog, updateSnapshotCatalog. Implements recoupable/chat#1801 Phase 2. Matches docs contract recoupable/docs#243. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: re-anchor POST /api/catalogs to merged contract + review fixes - Flatten request to the merged docs#243 contract: from:{snapshot_id} -> a root snapshot field (validator + handler + tests). Error copy follows. - DRY/SRP: drop the inline success() helper; use the shared successResponse(). - KISS rename: materializeSnapshotCatalog.ts -> createSnapshotCatalog.ts. - DRY: delete the redundant updateSnapshotCatalog helper; reuse the existing updatePlaycountSnapshot(id, fields). Validator change done red->green. lib/catalog: 24 tests pass; tsc + eslint clean. Addresses review on PR #677. * fix: materialize catalog songs from song_measurements, not snapshot.isrcs Testing the full materialize path surfaced a real bug: a valuation snapshot is album_ids-scoped, so its own isrcs column is null — createSnapshotCatalog read snapshot.isrcs and would link an EMPTY catalog. The measured ISRCs live in song_measurements (snapshot lineage), so source them there. New selectSnapshotIsrcs(snapshotId) helper (distinct song_measurements.song for the snapshot). createSnapshotCatalog now uses it. TDD: new createSnapshotCatalog.test.ts (3 tests) red->green; lib/catalog 27 pass. Addresses PR #677 verification. * refactor: reuse selectSongMeasurements (snapshot filter) instead of a new helper KISS/DRY per review: drop selectSnapshotIsrcs; add an optional snapshot filter to the existing selectSongMeasurements, and derive distinct ISRCs in createSnapshotCatalog. lib/catalog + song_measurements: 36 tests pass. Addresses review on PR #677. --------- Co-authored-by: Claude Opus 4.8 (1M context) * fix: LEFT-join artists in catalog-songs read (materialized tracks were hidden) (#681) * fix: LEFT-join artists in catalog-songs read so materialized tracks surface selectCatalogSongsWithArtists used song_artists!inner -> accounts!inner, so valuation-captured tracks (which have songs + song_measurements but no song_artists yet) were filtered out — a materialized catalog read back as 0 songs (verified live on api#677). Drop the two !inner so artist-less songs return with artists: []; songs!inner stays (catalog_songs.song FK guarantees it). Closes the read-path half of the song_artists follow-up in recoupable/chat#1801. Longer-term (option a): the capture pipeline should also write song_artists. * Update lib/supabase/catalog_songs/selectCatalogSongsWithArtists.ts * feat: add X (Twitter) + LinkedIn to the Composio connector whitelist (chat#1793) (#679) * feat: add X (Twitter) + LinkedIn to the Composio connector whitelist (chat#1793) Expand the existing whitelist pattern to two new platforms — no architecture changes: - SUPPORTED_TOOLKITS (getConnectors.ts) + ENABLED_TOOLKITS (getComposioTools.ts) - CONNECTOR_DISPLAY_NAMES: twitter → "X (Twitter)", linkedin → "LinkedIn" - buildAuthConfigs() reads COMPOSIO_TWITTER_AUTH_CONFIG_ID + COMPOSIO_LINKEDIN_AUTH_CONFIG_ID - document both env vars in .env.example TDD: new buildAuthConfigs unit + expanded getConnectors / handler / ENABLED_TOOLKITS assertions, RED before GREEN. Full lib/composio suite green (157 tests). Implements the contract from docs#244. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: fix lint/format — relocate ENABLED_TOOLKITS test block, reformat toolkit array - Move the ENABLED_TOOLKITS describe block below the imports (import/first) - Prettier-format the expanded toolkits array in getConnectors.test.ts Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) (#680) * feat: allow artists to connect X (Twitter); keep LinkedIn label-only (chat#1793) Add `twitter` to ALLOWED_ARTIST_CONNECTORS — artist-facing social, same class as tiktok/instagram/youtube. `linkedin` is intentionally left out (label/owner-only). TDD: isAllowedArtistConnector.test.ts asserts twitter allowed + linkedin excluded, RED before GREEN. Full lib/composio suite green (157 tests). Co-Authored-By: Claude Opus 4.8 (1M context) * feat: allow artists to connect LinkedIn too (chat#1793) Reversal of the earlier "LinkedIn label/owner-only" call: per owner decision 2026-06-18, LinkedIn is now an artist-facing connector like the others. Add `linkedin` to ALLOWED_ARTIST_CONNECTORS. TDD: flipped the linkedin assertions (now allowed/included), RED before GREEN. Full lib/composio suite green (159 tests). Co-Authored-By: Claude Opus 4.8 (1M context) * chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) The api copy of the artist connector allow-list had no runtime consumer — only its definition, test, and an (also-unused) barrel re-export. The connector routes are unopinionated (allow any connector for any account); the allow-list that actually drives the artist Connectors tab lives in `chat` (`lib/composio/allowedArtistConnectors.ts`). Removing the dead code. Supersedes the earlier plan to add twitter/linkedin to this api constant (decision: owner, 2026-06-18) — the artist allow-list is chat-only. Deletes isAllowedArtistConnector.ts + its test, and the barrel re-export. lib/composio suite green (149); no new tsc errors vs test (198 baseline). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * fix: enrich valuation-captured songs (artists + notes) so they render in the catalog (#684) * fix: enrich captured songs with artists + notes (root cause) The valuation capture path created songs rows from the Spotify track lookup but discarded track.artists and never ran the manual flow's enrichment, so captured songs had no song_artists and no notes -> the chat catalog view's isCompleteSong filter (artist + notes required, on by default) hid every valuation track (count shown, list empty). mapUnmappedAlbumTracks now carries track.artists through and runs the same enrichment as processSongsInput: linkSongsToArtists (auto-creates the artist account) + queueRedisSongs (queues note generation). TDD: new test asserts artists are linked + queued; lib/research/playcounts + lib/songs 109 tests pass. Root-cause follow-up on recoupable/chat#1801. * style: prettier-format the capture-enrichment test Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * fix(tasks): let admins fetch any task by id alone (cross-account read) (#689) GET /api/tasks scopes every lookup to the caller's own account. A lookup by `id` alone therefore returns nothing when the caller's key doesn't own the task, which blocks the background worker (customer-prompt-task) from loading a customer's scheduled task config with a shared admin key. When an admin caller queries by `id` with no `account_id` param, drop the account scope so the single task is returned regardless of owner. Non-admin id lookups stay scoped to the authenticated account (no cross-account leak). ValidatedGetTasksQuery.account_id is now optional; selectScheduledActions already filters by account_id only when present. TDD: RED (admin id lookup not cross-account, non-admin not scoped) -> GREEN. Fixes part of recoupable/chat#1810. Co-authored-by: Claude Opus 4.8 (1M context) * feat(connectors): POST /api/connectors/files — stage images for LinkedIn/X posts (#691) * feat(connectors): add POST /api/connectors/files (stage image for posts) Connector actions with file_uploadable fields (e.g. LINKEDIN_CREATE_LINKED_IN_POST.images[], TWITTER_CREATION_OF_A_POST) need a Composio { name, mimetype, s3key } descriptor whose s3key already lives in Composio storage. The execute path forwards parameters verbatim and never stages the file, so any s3key 404s. Add POST /api/connectors/files: given { url, toolSlug }, stage the image via composio.files.upload() and return flat { success, name, mimetype, s3key }. The caller passes that descriptor into parameters.images[] on the existing POST /api/connectors/actions. No change to the execute path (Option A). - uploadConnectorFile: calls composio.files.upload({ file: url, toolSlug, toolkitSlug }) where toolkitSlug is derived from the action slug. - validate body (zod { url, toolSlug }) + request (validateAuthContext gate; no account_id — upload is scoped by tool/toolkit, not connection). - handler returns 200 on success, 400 invalid body, 401 unauth, 502 upstream. URL-only input by decision; generic across file_uploadable toolkits (linkedin, twitter). TDD RED→GREEN; connectors suite green (129 tests). Implements recoupable/chat#1809. Docs: recoupable/docs#246. Co-Authored-By: Claude Opus 4.8 (1M context) * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(connectors): use shared safeParseJson in file-upload validator Address review (DRY): replace the raw `await request.json()` with the shared `safeParseJson` helper (lib/networking/safeParseJson), matching the other validators. Malformed JSON now yields a clean 400 via body validation instead of throwing into the handler's 502 path. TDD: added a malformed-JSON test (RED on request.json() throw) → GREEN. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * feat(artists): account_id override for DELETE /api/artists/{id} (#693) Parse an optional account_id from the request body and thread it into validateAuthContext(request, { accountId }), so a caller with access to multiple accounts (org members / Recoup admins) can delete an artist in another account's context. The resolved account is used for the checkAccountArtistAccess check; a non-admin passing an inaccessible account is still rejected by canAccessAccount (403). Mirrors the existing override pattern on POST /api/artists. chat#1811 Co-authored-by: Claude Opus 4.8 (1M context) * feat(chats): admins (RECOUP_ORG) can access any chat — read + write (#694) * feat(chats): account_id override for GET /api/chats/{id}/messages Parse an optional account_id (or camelCase accountId) query param in validateGetChatMessagesQuery, validate it as a UUID, and thread it into validateChatAccess via a new optional options arg. validateChatAccess forwards it to validateAuthContext(request, { accountId }) and resolves room access against the overridden account, so a caller with access to multiple accounts (org members / Recoup admins) can read another account's chat messages. A non-admin passing an inaccessible account is still rejected by canAccessAccount (403). The override is opt-in per call site: only validateGetChatMessagesQuery passes it, so the other validateChatAccess callers are unchanged. chat#1811 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chats): admin bypass (not account_id param) for GET messages Aligns GET /api/chats/{id}/messages with the shipped docs contract — docs#247 rolled back the account_id query param. The chat is identified by the path id and the owner is resolved server-side, so no param is needed. Instead, validateChatAccess gains an opt-in `allowAdmin` flag that grants RECOUP_ORG admins access to any room (mirrors checkAccountArtistAccess). Only the messages read path opts in; chat mutations (update/delete/copy) stay ownership-gated, so admin write access is not silently broadened. - drop account_id/accountId query parsing from validateGetChatMessagesQuery - validateChatAccess: remove accountId override; add allowAdmin + checkIsAdmin bypass - tests: admin bypass grants access; non-admin still 403 even with allowAdmin; mutation paths never consult admin status - mock checkIsAdmin in getChatArtistHandler.test.ts (now a transitive dep) Refs recoupable/chat#1811 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chats): drop allowAdmin flag — admins access any chat (read + write) YAGNI/KISS per internal review: RECOUP_ORG admins already have broad cross-account power (delete any artist, read any account), and chat ops are resource-scoped by chatId, so an unconditional admin bypass is the coherent model. Removes the opt-in flag entirely. The admin check now runs ONLY after the ownership check fails, so the common owner path never pays the extra checkIsAdmin lookup (better than both the flag and a top-of-function bypass). Applies across all validateChatAccess call sites (messages + getChatArtist reads; update/delete-trailing/copy mutations), so admins can read and write any account's chats; non-admins are unchanged (403). Refs recoupable/chat#1811 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chats): revert validateGetChatMessagesQuery (no change needed) The admin bypass lives entirely in validateChatAccess, which the messages endpoint already delegates to — so validateGetChatMessagesQuery needs no change. Reverts the doc-only edit and the redundant delegation test to keep the PR scoped to validateChatAccess. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) (#700) * feat(auth): ephemeral, account-scoped api keys (chat#1813) Foundation for the async chat-generation migration: the headless/scheduled path has no client Privy session to forward into the sandbox and must not put the long-lived service key into model-driven bash. It instead mints a short-lived, account-scoped recoup_sk_ key per run and deletes it on completion. - lib/keys/mintEphemeralAccountKey: generate+hash+insert a recoup_sk_ key with an expires_at (default 15m TTL); returns { rawKey, keyId } for injection + cleanup. - lib/keys/isApiKeyExpired: pure TTL check (NULL/unparseable = never expires). - getApiKeyAccountId: reject a key whose expires_at has passed (401). Backward compatible — existing long-lived keys have NULL expiry. - insertApiKey + database.types: carry the new account_api_keys.expires_at column. Depends on database#36 (adds the column). Security-sensitive (touches the api-key auth path) — please review the expiry-enforcement diff. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(auth): scope PR to expiry enforcement; defer key minting Remove mintEphemeralAccountKey + its test and revert the insertApiKey expires_at writer change. Both are orphaned in this PR — mint has no caller anywhere, and insertApiKey's expires_at param is only ever passed by mint. They belong with the re-point PR (handleChatGenerate) that actually mints + injects + deletes the key, so this PR stays a complete, testable slice: enforce expires_at on x-api-key auth (getApiKeyAccountId + isApiKeyExpired). The minting code + its wiring spec are preserved in the tracking issue (recoupable/chat#1813). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * refactor(chat): extract shared buildRunAgentInput (chat#1813) (#701) Pulls the RunAgentWorkflowInput construction out of handleChatWorkflowStream into a pure, shared builder so the interactive (/api/chat/workflow) and the upcoming headless (/api/chat/generate) callers construct workflow input identically. Repo identifiers and the recoup org id are derived from clone_url inside the builder — one source of truth, no caller duplication. Behavior-preserving: the interactive handler now delegates to buildRunAgentInput; existing handleChatWorkflowStream tests stay green (20), plus 4 new builder tests. Co-authored-by: Claude Opus 4.8 (1M context) * Re-point POST /api/chat/generate onto runAgentWorkflow + ephemeral key (chat#1813) (#704) * feat(chat): re-point /api/chat/generate onto runAgentWorkflow (chat#1813) Async chat generation now runs on the SAME durable runAgentWorkflow as interactive /api/chat instead of the synchronous legacy ToolLoopAgent. POST /api/chat/generate provisions a headless session + active sandbox, mints a short-lived account-scoped recoup_sk_ key for in-sandbox recoup-api calls, builds the shared workflow input via buildRunAgentInput, and start()s the run — returning { runId } with 202 immediately. Generation, message persistence, the credit charge, and key revocation happen server-side inside the workflow. - lib/keys/mintEphemeralAccountKey + insertApiKey expires_at writer (re-added from the deferred half of #700; minting now has its only consumer). - lib/chat/generate/validateGenerateRequest — x-api-key auth + prompt/messages normalization to UIMessage[]. - lib/chat/generate/provisionGenerateSession — ensurePersonalRepo → insertSession → insertChat → connectSandbox → updateSession(active) → discoverSkills. - lib/chat/handleChatGenerate — orchestrates provision → mint → start; revokes the key if the run never starts. - Ephemeral key injected as recoupAccessToken + threaded as agentContext.ephemeralKeyId; runAgentWorkflow's finally deletes it on run end (deleteEphemeralKeyStep). The ~15m expires_at TTL (enforced by #700) is the backstop. - Matches docs#249 (202 { runId } contract). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(chat): return { runId, chatId, sessionId } from /api/chat/generate The workflow runId alone can't be resolved back to the chat output. Return the persisted-output identifiers too so a caller can read the result later (GET /api/chat/{chatId}/stream, or the chat's persisted messages) — turning the endpoint from fire-and-forget-only into a proper async-job contract. The scheduled task still ignores the body. (chat#1813, review follow-up.) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): rename POST /api/chat/generate → POST /api/chat/runs REST cleanup (chat#1813): the endpoint starts a *run*, so it's modeled as a run resource, not a `generate` verb. Removes /generate entirely (no alias). - Route app/api/chat/generate → app/api/chat/runs; handler handleChatGenerate → handleStartChatRun. Add a Location header at /api/chat/runs/{runId}. - Update path strings in comments/JSDoc to /api/chat/runs. Also addresses cubic review on this PR: - validateGenerateRequest: trim prompt before the presence check (reject blank). - handleStartChatRun: standardized 500 body "Internal server error". - validateGenerateRequest test: use a schema-valid field so the "exactly one of prompt/messages" case is exercised for the right reason; add a whitespace-prompt test. (Internal helper names — validateGenerateRequest/provisionGenerateSession — keep "generate" as it describes the operation; renaming is out of scope.) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): drop dead roomId from the request schema roomId was accepted-but-ignored on the re-pointed endpoint (it mints its own session+chat per run and returns chatId/sessionId). Nothing sends it anymore (tasks#152 stopped), and Zod strips unknown keys regardless — so remove it from the schema to keep docs↔api in sync. excludeTools was already gone. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): remove topic param to match /api/chat /api/chat takes no session-title param, so /api/chat/runs shouldn't either. The endpoint provisions its own session with a default title; drop topic from the request schema and the GenerateRequest type. (chat#1813 review) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(chat/runs): implement GET /api/chat/runs/{runId} status endpoint Brings the api to parity with the merged docs#249, which documented the run- status endpoint. Wraps the durable workflow's getRun(runId).status and returns { runId, status } (normalized to queued|running|completed|failed|cancelled). 404 when the run is unknown; x-api-key auth. Returns { runId, status } rather than the documented chatId/sessionId: getRun exposes only status, and there's no durable runId→chat mapping (the caller already holds chatId/sessionId from the 202 start response). Docs reconciled to match; full chatId/sessionId + per-run ownership would need a chats.last_run_id column (follow-up). (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): SRP + DRY — share session/sandbox provisioning libs Addresses review on api#704: SRP — extract normalizeRunStatus into its own file (one exported fn per file). DRY — the headless provisionGenerateSession duplicated the interactive flow. Extract the shared blocks and use them in both paths: - lib/sessions/createSessionWithInitialChat — ensurePersonalRepo → insertSession → insertChat with rollback. Used by createSessionHandler (POST /api/sessions) AND provisionGenerateSession. Also fixes the headless rollback gap (cubic P2). - lib/sandbox/markSessionSandboxActive — bind sandbox state to a session + mark active. Used by createSandboxHandler (POST /api/sandbox) AND provisionGenerateSession. The sandbox connectSandbox call itself is left in each caller: the interactive createSandboxHandler interleaves org-snapshot warm-boot + one-shot (no-session) provisioning + skill-install + lifecycle-kick that the lean headless path intentionally omits, so forcing a shared connect would couple unrelated concerns. Behavior-preserving: full lib/sessions + lib/sandbox suites green; new unit tests for the 3 extracted fns. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat/runs): rename lib/chat/generate → lib/chat/runs (match the endpoint) The endpoint was renamed /api/chat/generate → /api/chat/runs, but the internal helpers kept "generate" — pointing at a removed concept, and split across two dirs (handleStartChatRun lived in lib/chat/, its helpers in lib/chat/generate/). Pure rename, no behavior change: - lib/chat/generate/ → lib/chat/runs/ (handleStartChatRun + its test moved in too) - validateGenerateRequest → validateChatRunRequest (file + symbol) - provisionGenerateSession → provisionRunSession (file + symbol) - ProvisionedGenerateSession → ProvisionedRunSession - generateBodySchema → chatRunBodySchema, GenerateRequest → ChatRunRequest - DEFAULT_GENERATE_MODEL_ID → DEFAULT_RUN_MODEL_ID - updated JSDoc refs in the shared createSandboxHandler / markSessionSandboxActive / createSessionWithInitialChat git mv preserves history. lib/chat/generateChatTitle (unrelated) left untouched. Feature suites green (126), tsc + lint clean. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * Retire OpenClaw prompt_sandbox → run-sandbox-command bridge (chat#1813) (#705) * refactor(sandbox): retire OpenClaw prompt_sandbox → run-sandbox-command bridge (chat#1813) Async agent work now runs on the durable runAgentWorkflow via POST /api/chat/generate, so the OpenClaw offload bridge is removed: - Delete lib/trigger/triggerPromptSandbox.ts (the only caller of tasks.trigger("run-sandbox-command")). - Delete the prompt_sandbox MCP tool (registerPromptSandboxTool) + its registration (lib/mcp/tools/sandbox/index.ts) and drop it from registerAllTools. - Simplify processCreateSandbox to bare sandbox creation (no prompt, no trigger); drop `prompt` from validateSandboxBody. POST /api/sandboxes now only provisions a sandbox. - Update JSDoc on the route + handler; prune prompt-mode tests. No api code calls run-sandbox-command anymore (grep clean). The shared OpenClaw helpers in the tasks repo stay until their other consumers are migrated (issue Phase 2). Stale prompt_sandbox references in the dead legacy generate stack (SYSTEM_PROMPT, getGeneralAgent, getMcpTools, setupToolsForRequest) are left for a follow-up cleanup PR. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(sandbox): /api/chat/generate → /api/chat/runs in retire-bridge comments The endpoint was renamed in api#704 (now on test/prod); update the JSDoc refs added by this PR to match. (chat#1813) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(prompt): remove prompt_sandbox from SYSTEM_PROMPT + create_knowledge_base Retiring the prompt_sandbox MCP tool (this PR) affects LIVE agents, not dead code: the legacy getGeneralAgent stack is still used by Slack chat (handleSlackChatMessage → setupChatRequest) and the inbound email responder (respondToInboundEmail → generateEmailResponse). Both run on SYSTEM_PROMPT and the MCP toolset, so removing the tool while the prompt instructs models to use it would tell live agents to call a tool that no longer exists. - SYSTEM_PROMPT: drop the entire "Sandbox-First Approach" section (it centered on prompt_sandbox as the "primary tool" + release-management-via-sandbox). - create_knowledge_base tool: drop the "(use prompt_sandbox for those)" pointer. - Update both tests to guard that neither references the retired tool. Behavior note: the Slack + email agents lose the prompt_sandbox (OpenClaw) sandbox tool — acceptable since OpenClaw is the failing component this issue removes. Those agents still run on the legacy getGeneralAgent stack (not runAgentWorkflow); migrating them is out of scope (chat#1813). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * feat: POST /api/emails + route ephemeral key to RECOUP_API_KEY (#1815) (#708) * feat(emails): POST /api/emails + route ephemeral key to RECOUP_API_KEY (#1815) Item 1 of recoupable/chat#1815 — let the sandbox agent (and scheduled report tasks) deliver email. POST /api/emails: send an email to explicit recipients, account-scoped via validateAuthContext, reusing the same processAndSendEmail domain fn as the send_email MCP tool (DRY). Mirrors POST /api/notifications but takes a required `to[]`. SRP: route → sendEmailHandler → validateSendEmailBody. Flat response { success, message, id }; 400/401/502 like the sibling. TDD red→green. buildRecoupExecEnv: route a recoup_sk_ token (the headless /api/chat/runs ephemeral key) to RECOUP_API_KEY (which the recoup-api skill sends as x-api-key) instead of RECOUP_ACCESS_TOKEN (Bearer). REST endpoints 401 a recoup_sk_ key over Bearer — this is why the sandbox agent's recoup-api calls were failing. Privy JWTs (interactive path) still route to RECOUP_ACCESS_TOKEN. Verified by diagnostic run: x-api-key → 200, Bearer → 401. Contract: recoupable/docs#251. Affected suites green (231); my files tsc + lint clean (other tsc errors pre-exist on test). Co-Authored-By: Claude Opus 4.8 (1M context) * feat: bring POST /api/emails to parity with docs#251 contract Documentation-driven follow-up to the merged docs#251 contract: 1. Rename the public request field room_id -> chat_id at the /api/emails boundary (schema, type, handler, route JSDoc). The internal processAndSendEmail/selectRoomWithArtist plumbing keeps room_id (same id value, rooms table) so the shared MCP send_email path is untouched. 2. Enforce the recipient restriction: without a payment method on file, to/cc are limited to the account's own email (403 otherwise); a card on file lifts it. New assertRecipientsAllowed + accountHasPaymentMethod helpers (read-only Stripe customer + default-payment-method lookup). Tests: assertRecipientsAllowed unit (card-on-file, own-email, blocked), handler chat_id mapping + 403 path, validate chat_id. 144 emails/notifications tests green; tsc adds 0 new errors; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(emails): address review — server-side token parsing, DRY, SRP Addresses the four review comments on api#708: 1. KISS (buildRecoupExecEnv): drop client-side token routing. The server now accepts a `recoup_sk_` API key over `Authorization: Bearer` too (getAuthenticatedAccountId parses the format), so buildRecoupExecEnv always sets a single RECOUP_ACCESS_TOKEN. New shared getAccountIdByApiKey is used by both the x-api-key and Bearer paths. 2. DRY (payment method): extract accountHasPaymentMethod into lib/stripe and reuse it in ensureSongstatsPaymentMethod (was duplicating the findStripeCustomer -> findDefaultPaymentMethod two-step). 3. SRP: move the recipient restriction out of the handler into validateSendEmailBody (alongside auth/validation). 4. KISS: validateSendEmailBody returns { ...result.data, accountId }. Tests: getAuthenticatedAccountId recoup_sk_ branch, recipient 403 moved to the validator suite, handler test now mocks the validator. 427 tests green across emails/auth/stripe/agent/research; tsc 0 new errors; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * fix(skills): install the renamed global skills into sandboxes (chat#1815) (#712) The sandbox agent never gets the recoup-api playbook, so scheduled "send an email" tasks complete with zero tool calls ("I don't have a tool to send emails"). Root cause: defaultGlobalSkillRefs installed `recoup-api` and `artist-workspace`, but both were renamed/split in recoupable/skills. The install runs `npx skills add recoupable/skills --skill recoup-api`, which throws on the unknown name (caught best-effort) → no platform skills land in the sandbox. Breaks all platform-skill loading, not just email. - defaultGlobalSkillRefs.ts: use the current slugs — recoup-platform-api-access, recoup-platform-build-workspace, recoup-roster-{add,list,manage}-artist (restores the old recoup-api + artist-workspace coverage, now split). - recoupApiSkillPrompt.ts: update the skill names the nudge tells the agent to load, and add send-email / deliver-report to the triggers so the agent loads recoup-platform-api-access for email tasks instead of claiming no tool. 569 tests green; tsc 0 new errors; lint clean. Co-authored-by: Claude Opus 4.8 (1M context) * feat(emails): make `to` and `subject` optional on POST /api/emails (#710) * feat: make `to` optional on POST /api/emails (default to account's own email) When `to` is omitted, resolve the authenticated account's own email(s) via account_emails and use them as recipients, so a caller can "email me this" without restating their address (the common scheduled-report case). `to` stays minItems:1 when provided. The recipient restriction is unchanged and runs on the resolved recipients (own email always allowed). 400 when `to` is omitted and the account has no email on file. Implements the merged contract docs#252. Part of chat#1815. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(emails): make subject optional, default from body (docs#252) Follows the merged docs#252 contract (subject dropped from required). Resend requires a non-empty subject, so resolve one server-side when the caller omits it: new resolveEmailSubject() returns the provided subject, else the body's first heading/line (text preferred, then HTML with tags stripped), else "Message from Recoup". validateSendEmailBody now returns a always-string subject; schema marks it optional. Tests: resolveEmailSubject unit (provided/derived/html/fallback/cap), validator subject-defaulting cases; removed the now-obsolete "rejects a missing subject" 400 test. 155 emails/notifications tests green; tsc 0 new errors; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(emails): extract firstMeaningfulLine + stripHtml to own files (SRP) Per review: one exported function per file. Move the two pure string helpers out of resolveEmailSubject.ts into lib/emails/firstMeaningfulLine.ts and lib/emails/stripHtml.ts, each with its own unit test. resolveEmailSubject now imports them. Behavior unchanged; 14 tests green, tsc/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * chore: remove POST /api/notifications (superseded by /api/emails) (#711) /api/notifications emailed only the account's own address. With `to` now optional on POST /api/emails (defaulting to the account's own email, api#710), /api/emails fully subsumes it, so we standardize on /api/emails and delete the duplicate route. Deletes app/api/notifications/route.ts and lib/notifications/* (handler, validator, tests). Keeps processAndSendEmail (the shared domain fn for the send_email MCP tool) and updates its stale JSDoc to reference /api/emails. Implements docs#253. Part of chat#1815 cleanup. grep for api/notifications / createNotification / lib/notifications is clean; emails suite green. Co-authored-by: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * fix: repoint dead .com hosts to live .dev (recoupable/chat#1819 §A+§B) Repoint the dead chat.recoupable.com and docs/developers.recoupable.com hosts to their live .dev equivalents, routing all chat-app links through the existing getFrontendBaseUrl() centralizer (DRY) and docs links through a new DOCS_BASE_URL constant in lib/const.ts. - getFrontendBaseUrl(): production fallback chat.recoupable.com -> .dev - getEmailFooter, buildTaskCard, handleGitHubWebhook: build chat/task links from getFrontendBaseUrl() instead of hardcoded .com literals - app/page.tsx: docs link -> DOCS_BASE_URL (docs.recoupable.dev) - app/api/chat/route.ts contract comment + recoupApiSkillPrompt: doc host developers.recoupable.com -> docs.recoupable.dev - extractRoomIdFromHtml/Text: widen host regex to recoupable.(com|dev) so post-migration .dev links extract while legacy .com links in flight still match. RED->GREEN test added for the .dev case in both suites. Excluded: lib/credits/const.ts sandbox.recoupable.com link — sandbox.recoupable.dev is not yet provisioned (404), so it stays .com pending a sandbox .dev domain. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/chat/route.ts | 2 +- app/page.tsx | 3 +- lib/agents/__tests__/buildTaskCard.test.ts | 5 ++-- lib/agents/buildTaskCard.ts | 5 ++-- lib/chat/recoupApiSkillPrompt.ts | 2 +- lib/coding-agent/handleGitHubWebhook.ts | 3 +- lib/composio/getFrontendBaseUrl.ts | 2 +- lib/const.ts | 3 ++ lib/emails/__tests__/getEmailFooter.test.ts | 5 ++-- lib/emails/getEmailFooter.ts | 7 +++-- .../__tests__/extractRoomIdFromHtml.test.ts | 16 ++++++++++ .../__tests__/extractRoomIdFromText.test.ts | 9 ++++++ lib/emails/inbound/extractRoomIdFromHtml.ts | 30 +++++++++++-------- lib/emails/inbound/extractRoomIdFromText.ts | 4 ++- 14 files changed, 69 insertions(+), 27 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 2df3c5016..8af93ad7a 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -25,7 +25,7 @@ export async function OPTIONS() { * retained as a backward-compatible alias while consumers (chat, * open-agents, API-key callers) migrate to `/api/chat`. * - * Contract: https://developers.recoupable.com/api-reference/chat/workflow + * Contract: https://docs.recoupable.dev/api-reference/chat/workflow * * @param request - The incoming NextRequest. * @returns A streaming Response (200) or a NextResponse error. diff --git a/app/page.tsx b/app/page.tsx index a6a6b0508..139add5d1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { DOCS_BASE_URL } from "@/lib/const"; export default function Home() { return ( @@ -24,7 +25,7 @@ export default function Home() {
diff --git a/lib/agents/__tests__/buildTaskCard.test.ts b/lib/agents/__tests__/buildTaskCard.test.ts index b8c50525f..4b4a45ea7 100644 --- a/lib/agents/__tests__/buildTaskCard.test.ts +++ b/lib/agents/__tests__/buildTaskCard.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { buildTaskCard } from "../buildTaskCard"; import { LinkButton } from "chat"; +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; vi.mock("chat", () => ({ Card: vi.fn(({ title, children }) => ({ type: "card", title, children })), @@ -28,7 +29,7 @@ describe("buildTaskCard", () => { buttons: [ { type: "linkButton", - url: "https://chat.recoupable.com/tasks/run-abc-123", + url: `${getFrontendBaseUrl()}/tasks/run-abc-123`, label: "View Task", }, ], @@ -42,7 +43,7 @@ describe("buildTaskCard", () => { buildTaskCard("Title", "Message", "my-run-id"); expect(LinkButton).toHaveBeenCalledWith({ - url: "https://chat.recoupable.com/tasks/my-run-id", + url: `${getFrontendBaseUrl()}/tasks/my-run-id`, label: "View Task", }); }); diff --git a/lib/agents/buildTaskCard.ts b/lib/agents/buildTaskCard.ts index 27869c76d..de4ae0fc1 100644 --- a/lib/agents/buildTaskCard.ts +++ b/lib/agents/buildTaskCard.ts @@ -1,4 +1,5 @@ import { Card, CardText, Actions, LinkButton } from "chat"; +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; /** * Builds a Card with a message and a View Task button. @@ -13,9 +14,7 @@ export function buildTaskCard(title: string, message: string, runId: string) { title, children: [ CardText(message), - Actions([ - LinkButton({ url: `https://chat.recoupable.com/tasks/${runId}`, label: "View Task" }), - ]), + Actions([LinkButton({ url: `${getFrontendBaseUrl()}/tasks/${runId}`, label: "View Task" })]), ], }); } diff --git a/lib/chat/recoupApiSkillPrompt.ts b/lib/chat/recoupApiSkillPrompt.ts index 446c3cd54..2a0fb41c9 100644 --- a/lib/chat/recoupApiSkillPrompt.ts +++ b/lib/chat/recoupApiSkillPrompt.ts @@ -12,4 +12,4 @@ * (recoupable/chat#1815). */ export const recoupApiSkillPrompt = - 'If you\'re asked to do anything involving their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, subscriptions, **sending an email or delivering a report**, or any other resource/action at recoup-api.vercel.app / developers.recoupable.com — load the right skill first instead of guessing or assuming you lack a tool. For live data or actions against the API (socials, posts, metrics, research, tasks, and **sending email via `POST /api/emails`** — e.g. "email X to Y", scheduled-report output) load `recoup-platform-api-access`; when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID`) so you get the sandbox\'s org, not every org the user belongs to. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `recoup-roster-list-artists` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (it is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist") load `recoup-roster-add-artist`; to operate inside one artist\'s folder load `recoup-roster-manage-artist`; to scaffold the folder tree load `recoup-platform-build-workspace`. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.'; + 'If you\'re asked to do anything involving their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, subscriptions, **sending an email or delivering a report**, or any other resource/action at recoup-api.vercel.app / docs.recoupable.dev — load the right skill first instead of guessing or assuming you lack a tool. For live data or actions against the API (socials, posts, metrics, research, tasks, and **sending email via `POST /api/emails`** — e.g. "email X to Y", scheduled-report output) load `recoup-platform-api-access`; when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID`) so you get the sandbox\'s org, not every org the user belongs to. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `recoup-roster-list-artists` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (it is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist") load `recoup-roster-add-artist`; to operate inside one artist\'s folder load `recoup-roster-manage-artist`; to scaffold the folder tree load `recoup-platform-build-workspace`. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.'; diff --git a/lib/coding-agent/handleGitHubWebhook.ts b/lib/coding-agent/handleGitHubWebhook.ts index fd6e5fc93..7153822d7 100644 --- a/lib/coding-agent/handleGitHubWebhook.ts +++ b/lib/coding-agent/handleGitHubWebhook.ts @@ -6,6 +6,7 @@ import { extractPRComment } from "./extractPRComment"; import { getCodingAgentPRState, setCodingAgentPRState } from "./prState"; import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; import { postGitHubComment } from "./postGitHubComment"; +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; const BOT_MENTION = "@recoup-coding-agent"; @@ -97,7 +98,7 @@ export async function handleGitHubWebhook(request: Request): Promise { it("includes reply note in all cases", () => { @@ -21,14 +22,14 @@ describe("getEmailFooter", () => { it("includes chat link when roomId is provided", () => { const roomId = "test-room-123"; const footer = getEmailFooter(roomId); - expect(footer).toContain(`https://chat.recoupable.com/chat/${roomId}`); + expect(footer).toContain(`${getFrontendBaseUrl()}/chat/${roomId}`); expect(footer).toContain("Or continue the conversation on Recoup"); }); it("generates proper HTML with roomId", () => { const roomId = "my-room-id"; const footer = getEmailFooter(roomId); - expect(footer).toContain(`href="https://chat.recoupable.com/chat/${roomId}"`); + expect(footer).toContain(`href="${getFrontendBaseUrl()}/chat/${roomId}"`); expect(footer).toContain('target="_blank"'); expect(footer).toContain('rel="noopener noreferrer"'); }); diff --git a/lib/emails/getEmailFooter.ts b/lib/emails/getEmailFooter.ts index 6fe173d0b..3d211f798 100644 --- a/lib/emails/getEmailFooter.ts +++ b/lib/emails/getEmailFooter.ts @@ -1,3 +1,5 @@ +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; + /** * Generates a standardized email footer HTML. * @@ -6,6 +8,7 @@ * @returns HTML string for the email footer. */ export function getEmailFooter(roomId?: string, artistName?: string): string { + const chatUrl = roomId ? `${getFrontendBaseUrl()}/chat/${roomId}` : ""; const artistLine = artistName ? `

@@ -22,8 +25,8 @@ export function getEmailFooter(roomId?: string, artistName?: string): string { ? `

Or continue the conversation on Recoup: - - https://chat.recoupable.com/chat/${roomId} + + ${chatUrl}

`.trim() : ""; diff --git a/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts index d7aebeea3..4a7231d81 100644 --- a/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts +++ b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts @@ -60,6 +60,22 @@ describe("extractRoomIdFromHtml", () => { }); }); + describe(".dev domain (post-migration links)", () => { + it("extracts roomId from a chat.recoupable.dev link", () => { + const html = ` + + +

Continue the conversation: https://chat.recoupable.dev/chat/b2c3d4e5-f6a7-8901-bcde-f23456789012

+ + + `; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("b2c3d4e5-f6a7-8901-bcde-f23456789012"); + }); + }); + describe("Gmail reply with proper threading", () => { it("extracts roomId from Gmail reply with quoted content", () => { const html = ` diff --git a/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts b/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts index 2db2eb66d..e817ae160 100644 --- a/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts +++ b/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts @@ -27,6 +27,15 @@ describe("extractRoomIdFromText", () => { expect(result).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); }); + it("extracts roomId from a chat.recoupable.dev link (post-migration)", () => { + const text = + "Check out this chat: https://chat.recoupable.dev/chat/b2c3d4e5-f6a7-8901-bcde-f23456789012"; + + const result = extractRoomIdFromText(text); + + expect(result).toBe("b2c3d4e5-f6a7-8901-bcde-f23456789012"); + }); + it("handles case-insensitive domain matching", () => { const text = "Visit HTTPS://CHAT.RECOUPABLE.COM/CHAT/12345678-1234-1234-1234-123456789abc"; diff --git a/lib/emails/inbound/extractRoomIdFromHtml.ts b/lib/emails/inbound/extractRoomIdFromHtml.ts index 6a48f954b..0b29745f9 100644 --- a/lib/emails/inbound/extractRoomIdFromHtml.ts +++ b/lib/emails/inbound/extractRoomIdFromHtml.ts @@ -1,16 +1,21 @@ const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; -// Matches chat.recoupable.com/chat/{uuid} in various formats: -// - Direct URL: https://chat.recoupable.com/chat/uuid -// - URL-encoded (in tracking redirects): chat.recoupable.com%2Fchat%2Fuuid +// Matches chat.recoupable.com or chat.recoupable.dev /chat/{uuid} in various formats. +// Both hosts are recognized so legacy .com links still in flight resolve alongside +// post-migration .dev links: +// - Direct URL: https://chat.recoupable.dev/chat/uuid +// - URL-encoded (in tracking redirects): chat.recoupable.dev%2Fchat%2Fuuid const CHAT_LINK_PATTERNS = [ - new RegExp(`https?://chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i"), - new RegExp(`chat\\.recoupable\\.com%2Fchat%2F(${UUID_PATTERN})`, "i"), + new RegExp(`https?://chat\\.recoupable\\.(com|dev)/chat/(${UUID_PATTERN})`, "i"), + new RegExp(`chat\\.recoupable\\.(com|dev)%2Fchat%2F(${UUID_PATTERN})`, "i"), ]; // Pattern to find UUID after /chat/ or %2Fchat%2F in link text that may contain tags -// The link text version: "https:///chat.recoupable.com/chat/uuid" -const WBR_STRIPPED_PATTERN = new RegExp(`chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i"); +// The link text version: "https:///chat.recoupable.dev/chat/uuid" +const WBR_STRIPPED_PATTERN = new RegExp( + `chat\\.recoupable\\.(com|dev)/chat/(${UUID_PATTERN})`, + "i", +); /** * Extracts the roomId from email HTML by looking for a Recoup chat link. @@ -25,11 +30,12 @@ const WBR_STRIPPED_PATTERN = new RegExp(`chat\\.recoupable\\.com/chat/(${UUID_PA export function extractRoomIdFromHtml(html: string | undefined): string | undefined { if (!html) return undefined; - // Try direct URL patterns first (most common case) + // Try direct URL patterns first (most common case). + // Group 1 is the host suffix (com|dev); group 2 is the UUID. for (const pattern of CHAT_LINK_PATTERNS) { const match = html.match(pattern); - if (match?.[1]) { - return match[1]; + if (match?.[2]) { + return match[2]; } } @@ -37,8 +43,8 @@ export function extractRoomIdFromHtml(html: string | undefined): string | undefi // This handles Superhuman's link text formatting: "https://chat...." const strippedHtml = html.replace(//gi, ""); const strippedMatch = strippedHtml.match(WBR_STRIPPED_PATTERN); - if (strippedMatch?.[1]) { - return strippedMatch[1]; + if (strippedMatch?.[2]) { + return strippedMatch[2]; } return undefined; diff --git a/lib/emails/inbound/extractRoomIdFromText.ts b/lib/emails/inbound/extractRoomIdFromText.ts index 446bdbbef..b6a0aba3b 100644 --- a/lib/emails/inbound/extractRoomIdFromText.ts +++ b/lib/emails/inbound/extractRoomIdFromText.ts @@ -1,4 +1,6 @@ -const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.com\/chat\/([0-9a-f-]{36})/i; +// Recognizes both the legacy .com host (links still in flight) and the +// post-migration .dev host. (?:com|dev) is non-capturing so the UUID stays group 1. +const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.(?:com|dev)\/chat\/([0-9a-f-]{36})/i; /** * Extracts the roomId from the email text body by looking for a Recoup chat link. From e6610b45ffd3dcfb8c6b13c7e3b5e124dd1b404d Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 29 Jun 2026 19:16:54 -0500 Subject: [PATCH 22/27] fix: repoint remaining .com refs missed by api#719 (domain cutover) (#721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three references outside api#719's touched-domain test suites: - sendApifyWebhookEmail: the Recoup Chat CTA link in the LLM email prompt now uses getFrontendBaseUrl() (→ chat.recoupable.dev in prod) instead of the dead chat.recoupable.com literal (DRY, same centralizer as the other email links). - registerGetApiKeyTool: the MCP tool description + JSDoc pointed the LLM at the dead api.recoupable.com / developers.recoupable.com → api.recoupable.dev / docs.recoupable.dev. - getCatalogsHandler: JSDoc legacy-endpoint reference → api.recoupable.dev. Refs recoupable/chat#1819. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/apify/sendApifyWebhookEmail.ts | 3 ++- lib/catalog/getCatalogsHandler.ts | 2 +- lib/mcp/tools/registerGetApiKeyTool.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/apify/sendApifyWebhookEmail.ts b/lib/apify/sendApifyWebhookEmail.ts index 29ec19a94..051db1495 100644 --- a/lib/apify/sendApifyWebhookEmail.ts +++ b/lib/apify/sendApifyWebhookEmail.ts @@ -1,6 +1,7 @@ import generateText from "@/lib/ai/generateText"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; import type { ApifyInstagramProfileResult } from "@/lib/apify/types"; /** @@ -36,7 +37,7 @@ Latest Posts: ${(Array.isArray(profile.latestPosts) ? profile.latestPosts : []). write beautiful html email. subject: New Apify Dataset Notification. you're notifying music managers about new posts being available for one of their roster musician's Instagram profile. include a link to view the instagram profile. - call to action is to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents): https://chat.recoupable.com/?q=tell%20me%20about%20my%20latest%20Ig%20posts + call to action is to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents): ${getFrontendBaseUrl()}/?q=tell%20me%20about%20my%20latest%20Ig%20posts You'll be passed a dataset summary for a musician profile and their latest posts on instagram. your goal is to get the recipient to click a cta link to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents). only include the email body html. diff --git a/lib/catalog/getCatalogsHandler.ts b/lib/catalog/getCatalogsHandler.ts index b383213c2..7d77219e2 100644 --- a/lib/catalog/getCatalogsHandler.ts +++ b/lib/catalog/getCatalogsHandler.ts @@ -8,7 +8,7 @@ import { selectAccountCatalogs } from "@/lib/supabase/account_catalogs/selectAcc * * Lists catalogs linked to the account via `account_catalogs`, ordered by * `created_at desc`. Response body is byte-identical to the legacy - * `GET /api/catalogs?account_id=...` endpoint on `api.recoupable.com`. + * `GET /api/catalogs?account_id=...` endpoint on `api.recoupable.dev`. * * @param request - The incoming request * @param params - Route params containing the account ID diff --git a/lib/mcp/tools/registerGetApiKeyTool.ts b/lib/mcp/tools/registerGetApiKeyTool.ts index fecd49176..5f8b86b28 100644 --- a/lib/mcp/tools/registerGetApiKeyTool.ts +++ b/lib/mcp/tools/registerGetApiKeyTool.ts @@ -10,7 +10,7 @@ import { getToolResultError } from "@/lib/mcp/getToolResultError"; * Registers the "get_api_key" tool on the MCP server. * * Returns the Recoup API key the caller authenticated this MCP connection - * with so the LLM can use it for direct HTTP requests to api.recoupable.com + * with so the LLM can use it for direct HTTP requests to api.recoupable.dev * (via the x-api-key header). The MCP Bearer header is opaque to the LLM by * design, so without this tool, skills that curl /api/* endpoints have no * credential to send. @@ -22,7 +22,7 @@ export function registerGetApiKeyTool(server: McpServer): void { "get_api_key", { description: - "Return the Recoup API key for this session so the LLM can use it for direct HTTP calls to api.recoupable.com (x-api-key header, or Authorization: Bearer). Call this once when invoking any skill that makes raw HTTPS requests to the Recoup REST API — for example the recoup-api skill. The returned value is the same credential the customer used to authenticate this MCP connection. Endpoint reference: https://developers.recoupable.com (and https://developers.recoupable.com/llms.txt for the LLM-readable index).", + "Return the Recoup API key for this session so the LLM can use it for direct HTTP calls to api.recoupable.dev (x-api-key header, or Authorization: Bearer). Call this once when invoking any skill that makes raw HTTPS requests to the Recoup REST API — for example the recoup-api skill. The returned value is the same credential the customer used to authenticate this MCP connection. Endpoint reference: https://docs.recoupable.dev (and https://docs.recoupable.dev/llms.txt for the LLM-readable index).", inputSchema: z.object({}), }, async (_args, extra: RequestHandlerExtra) => { From 8a3d08480e3ec043dbb2dc595904e39bd57e24f1 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 29 Jun 2026 20:50:33 -0500 Subject: [PATCH 23/27] fix(chat/runs): install global skills in the headless run path (#722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provisionRunSession (POST /api/chat/runs, used by scheduled customer-prompt tasks) only DISCOVERED skills and never installed them — installSessionGlobalSkills ran only in createSandboxHandler (interactive POST /api/sandbox). So headless sandboxes had an empty .agents/skills/ dir, discoverSkills returned [], the agent got no `skill` tool, and it fell back to guessing API endpoints and fabricating data (chat#1822). Install global skills after connect + before discovery, best-effort (mirrors createSandboxHandler) so a failed install never blocks the run. Refs recoupable/chat#1822 Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/provisionRunSession.test.ts | 80 +++++++++++++++++++ lib/chat/runs/provisionRunSession.ts | 13 +++ 2 files changed, 93 insertions(+) create mode 100644 lib/chat/runs/__tests__/provisionRunSession.test.ts diff --git a/lib/chat/runs/__tests__/provisionRunSession.test.ts b/lib/chat/runs/__tests__/provisionRunSession.test.ts new file mode 100644 index 000000000..3dd3a13b3 --- /dev/null +++ b/lib/chat/runs/__tests__/provisionRunSession.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { provisionRunSession } from "../provisionRunSession"; +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { discoverSkills } from "@/lib/skills/discoverSkills"; +import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; + +vi.mock("@/lib/sessions/createSessionWithInitialChat", () => ({ + createSessionWithInitialChat: vi.fn(), +})); +vi.mock("@/lib/sandbox/factory", () => ({ connectSandbox: vi.fn() })); +vi.mock("@/lib/sandbox/getSessionSandboxName", () => ({ + getSessionSandboxName: vi.fn(() => "sandbox-name"), +})); +vi.mock("@/lib/sandbox/resolveGitUser", () => ({ + resolveGitUser: vi.fn(async () => ({ name: "x", email: "y" })), +})); +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(() => "gh-token"), +})); +vi.mock("@/lib/sandbox/markSessionSandboxActive", () => ({ + markSessionSandboxActive: vi.fn(), +})); +vi.mock("@/lib/skills/discoverSkills", () => ({ discoverSkills: vi.fn(async () => []) })); +vi.mock("@/lib/skills/getSandboxSkillDirectories", () => ({ + getSandboxSkillDirectories: vi.fn(async () => ["/skills"]), +})); +vi.mock("@/lib/sandbox/installSessionGlobalSkills", () => ({ + installSessionGlobalSkills: vi.fn(async () => undefined), +})); + +const session = { + id: "session-1", + clone_url: "https://github.com/org/repo", + sandbox_state: { type: "vercel" }, +}; +const updated = { ...session }; +const chat = { id: "chat-1" }; +const sandbox = { + getState: () => ({ type: "vercel" }), + workingDirectory: "/work", +}; + +describe("provisionRunSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createSessionWithInitialChat).mockResolvedValue({ + ok: true, + session, + chat, + } as never); + vi.mocked(connectSandbox).mockResolvedValue(sandbox as never); + vi.mocked(markSessionSandboxActive).mockResolvedValue(updated as never); + }); + + it("installs global skills into the sandbox before discovering them", async () => { + await provisionRunSession({ accountId: "account-1", title: "t" }); + + // Headless runs must PROVISION skills, not just discover them (chat#1822). + expect(installSessionGlobalSkills).toHaveBeenCalledWith({ + sessionRow: updated, + sandbox, + }); + + const installOrder = vi.mocked(installSessionGlobalSkills).mock.invocationCallOrder[0]; + const discoverOrder = vi.mocked(discoverSkills).mock.invocationCallOrder[0]; + expect(installOrder).toBeLessThan(discoverOrder); + }); + + it("still completes the run when skill install fails (best-effort)", async () => { + vi.mocked(installSessionGlobalSkills).mockRejectedValueOnce(new Error("install boom")); + + const result = await provisionRunSession({ accountId: "account-1", title: "t" }); + + expect(result.session).toEqual(updated); + expect(discoverSkills).toHaveBeenCalled(); + }); +}); diff --git a/lib/chat/runs/provisionRunSession.ts b/lib/chat/runs/provisionRunSession.ts index f1ed3bd95..df1b054f8 100644 --- a/lib/chat/runs/provisionRunSession.ts +++ b/lib/chat/runs/provisionRunSession.ts @@ -5,6 +5,7 @@ import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; import { discoverSkills } from "@/lib/skills/discoverSkills"; import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; @@ -76,6 +77,18 @@ export async function provisionRunSession({ const updated = await markSessionSandboxActive(session, sandbox.getState() as Json); if (!updated) throw new Error("Failed to activate session sandbox"); + // Install global skills BEFORE discovery — the headless run path must + // provision skills, not just discover them. Without this the sandbox skills + // dir is empty, `discoverSkills` returns [], the agent gets no `skill` tool, + // and it falls back to guessing API endpoints + fabricating (chat#1822). + // Best-effort: a failed install must not block the run (mirrors + // `createSandboxHandler`). + try { + await installSessionGlobalSkills({ sessionRow: updated, sandbox }); + } catch (error) { + console.error("[provisionRunSession] installSessionGlobalSkills failed:", error); + } + // Best-effort skill + working-directory discovery from the live handle — // a failure falls back to defaults so the run can still start (tools surface // the underlying issue when they reconnect). Mirrors handleChatWorkflowStream. From d7b06ceafbedc9093164103028cab130627f223e Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 29 Jun 2026 21:27:45 -0500 Subject: [PATCH 24/27] =?UTF-8?q?HOLD=20MERGE=20=E2=80=94=20api=20brand=20?= =?UTF-8?q?email=20=E2=86=92=20@recoupable.dev=20(+=20inbound=20@mail.reco?= =?UTF-8?q?upable.dev)=20(#725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * HOLD MERGE: move brand email to @recoupable.dev (+ inbound @mail.recoupable.dev) - OUTBOUND_EMAIL_DOMAIN @recoupable.com → @recoupable.dev (RECOUP_FROM_EMAIL derives from it: agent@recoupable.dev) - INBOUND_EMAIL_DOMAIN @mail.recoupable.com → @mail.recoupable.dev - shared@ (getSharedAccountConnections), sidney@ (isTestEmail), noreply (resolveGitUser), and from-address JSDoc → .dev - updated the coupled test assertions (getFromWithName, processAndSendEmail, resolveGitUser) to the new domains DO NOT MERGE until the recoupable.dev sending domain is verified in Resend AND MX/inbound on mail.recoupable.dev is live — else outbound bounces and inbound replies stop arriving. Per recoupable/chat#1819 decision 2026-06-29. Refs recoupable/chat#1819. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: inbound email domain → apex @recoupable.dev (never mail.recoupable.dev) Per decision: use the apex @recoupable.dev for BOTH outbound and inbound — no mail. subdomain. The inbound MX already sits on the apex recoupable.dev, so code + DNS now agree. INBOUND_EMAIL_DOMAIN @mail.recoupable.dev → @recoupable.dev; getFromWithName's local-part-preserving swap is unchanged (now domain-identity). Tests updated. recoupable.dev is verified in Resend, so outbound sends. Refs recoupable/chat#1819. Co-Authored-By: Claude Opus 4.8 (1M context) * style: prettier fix for getFromWithName test (shorter .dev addresses) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/emails/route.ts | 2 +- .../toolRouter/getSharedAccountConnections.ts | 2 +- lib/const.ts | 8 ++-- .../__tests__/processAndSendEmail.test.ts | 2 +- .../inbound/__tests__/getFromWithName.test.ts | 45 +++++++++---------- lib/emails/inbound/getFromWithName.ts | 2 +- lib/emails/isTestEmail.ts | 2 +- lib/emails/sendEmailHandler.ts | 2 +- lib/sandbox/__tests__/resolveGitUser.test.ts | 6 +-- lib/sandbox/resolveGitUser.ts | 2 +- 10 files changed, 35 insertions(+), 38 deletions(-) diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts index df3555ae8..412379eab 100644 --- a/app/api/emails/route.ts +++ b/app/api/emails/route.ts @@ -18,7 +18,7 @@ export async function OPTIONS() { * POST /api/emails * * Sends an email to one or more explicit recipients via Resend. Emails are sent - * from `Agent by Recoup `. Account-scoped — requires + * from `Agent by Recoup `. Account-scoped — requires * authentication via x-api-key header or Authorization Bearer token. * * Body parameters: diff --git a/lib/composio/toolRouter/getSharedAccountConnections.ts b/lib/composio/toolRouter/getSharedAccountConnections.ts index 56e63c342..3facaa756 100644 --- a/lib/composio/toolRouter/getSharedAccountConnections.ts +++ b/lib/composio/toolRouter/getSharedAccountConnections.ts @@ -15,7 +15,7 @@ const SHARED_ACCOUNT_ID = "recoup-shared-767f498e-e1e9-43c6-a152-a96ae3bd8d07"; * Get Google Drive/Sheets/Docs connections from the shared Recoupable account. * * When a customer doesn't want to grant full Google Drive access, - * they can share specific files with shared@recoupable.com instead. + * they can share specific files with shared@recoupable.dev instead. * This function fetches the shared account's connections so they * can be used as a fallback in tool router sessions. * diff --git a/lib/const.ts b/lib/const.ts index 47dc64d85..5b178dd05 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -15,11 +15,11 @@ export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; /** Base URL for the public API documentation site */ export const DOCS_BASE_URL = "https://docs.recoupable.dev"; -/** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */ -export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com"; +/** Domain for receiving inbound emails (e.g., support@recoupable.dev) */ +export const INBOUND_EMAIL_DOMAIN = "@recoupable.dev"; -/** Domain for sending outbound emails (e.g., support@recoupable.com) */ -export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; +/** Domain for sending outbound emails (e.g., support@recoupable.dev) */ +export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.dev"; /** Default from address for outbound emails */ export const RECOUP_FROM_EMAIL = `Agent by Recoup `; diff --git a/lib/emails/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts index 4a6b14d23..f4940b133 100644 --- a/lib/emails/__tests__/processAndSendEmail.test.ts +++ b/lib/emails/__tests__/processAndSendEmail.test.ts @@ -34,7 +34,7 @@ describe("processAndSendEmail", () => { } expect(mockSendEmailWithResend).toHaveBeenCalledWith( expect.objectContaining({ - from: "Agent by Recoup ", + from: "Agent by Recoup ", to: ["user@example.com"], subject: "Test", html: expect.stringContaining("Hello world"), diff --git a/lib/emails/inbound/__tests__/getFromWithName.test.ts b/lib/emails/inbound/__tests__/getFromWithName.test.ts index 3b0c867d8..7dfaa6e40 100644 --- a/lib/emails/inbound/__tests__/getFromWithName.test.ts +++ b/lib/emails/inbound/__tests__/getFromWithName.test.ts @@ -3,81 +3,78 @@ import { getFromWithName } from "../getFromWithName"; describe("getFromWithName", () => { describe("outbound domain conversion", () => { - it("converts inbound @mail.recoupable.com to outbound @recoupable.com", () => { - const result = getFromWithName(["support@mail.recoupable.com"]); + it("converts inbound @recoupable.dev to outbound @recoupable.dev", () => { + const result = getFromWithName(["support@recoupable.dev"]); - expect(result).toBe("Support by Recoup "); + expect(result).toBe("Support by Recoup "); }); it("preserves the email name when converting domains", () => { - const result = getFromWithName(["agent@mail.recoupable.com"]); + const result = getFromWithName(["agent@recoupable.dev"]); - expect(result).toBe("Agent by Recoup "); + expect(result).toBe("Agent by Recoup "); }); }); describe("finding inbound email", () => { it("finds recoup email in to array", () => { - const result = getFromWithName(["hello@mail.recoupable.com"]); + const result = getFromWithName(["hello@recoupable.dev"]); - expect(result).toBe("Hello by Recoup "); + expect(result).toBe("Hello by Recoup "); }); it("finds recoup email among multiple to addresses", () => { const result = getFromWithName([ "other@example.com", - "support@mail.recoupable.com", + "support@recoupable.dev", "another@example.com", ]); - expect(result).toBe("Support by Recoup "); + expect(result).toBe("Support by Recoup "); }); it("falls back to cc array when not in to array", () => { - const result = getFromWithName(["other@example.com"], ["support@mail.recoupable.com"]); + const result = getFromWithName(["other@example.com"], ["support@recoupable.dev"]); - expect(result).toBe("Support by Recoup "); + expect(result).toBe("Support by Recoup "); }); it("prefers to array over cc array", () => { - const result = getFromWithName( - ["to-agent@mail.recoupable.com"], - ["cc-agent@mail.recoupable.com"], - ); + const result = getFromWithName(["to-agent@recoupable.dev"], ["cc-agent@recoupable.dev"]); - expect(result).toBe("To-agent by Recoup "); + expect(result).toBe("To-agent by Recoup "); }); it("handles case-insensitive domain matching", () => { - const result = getFromWithName(["Support@MAIL.RECOUPABLE.COM"]); + const result = getFromWithName(["Support@RECOUPABLE.DEV"]); - expect(result).toBe("Support by Recoup "); + expect(result).toBe("Support by Recoup "); }); }); describe("error handling", () => { it("throws error when no recoup email found in to or cc", () => { expect(() => getFromWithName(["other@example.com"])).toThrow( - "No email found ending with @mail.recoupable.com", + "No email found ending with @recoupable.dev", ); }); it("throws error when arrays are empty", () => { - expect(() => getFromWithName([])).toThrow("No email found ending with @mail.recoupable.com"); + expect(() => getFromWithName([])).toThrow("No email found ending with @recoupable.dev"); }); }); describe("name formatting", () => { it("capitalizes first letter of name", () => { - const result = getFromWithName(["lowercase@mail.recoupable.com"]); + const result = getFromWithName(["lowercase@recoupable.dev"]); - expect(result).toBe("Lowercase by Recoup "); + expect(result).toBe("Lowercase by Recoup "); }); it("preserves rest of name casing", () => { - const result = getFromWithName(["myAgent@mail.recoupable.com"]); + const result = getFromWithName(["myAgent@recoupable.dev"]); - expect(result).toBe("MyAgent by Recoup "); + expect(result).toBe("MyAgent by Recoup "); }); }); }); diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts index cac863610..53a8e8f63 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -6,7 +6,7 @@ import { OUTBOUND_EMAIL_DOMAIN, INBOUND_EMAIL_DOMAIN } from "@/lib/const"; * * @param toEmails - Array of email addresses from the 'to' field * @param ccEmails - Optional array of email addresses from the 'cc' field (fallback) - * @returns Formatted email address with display name (e.g., "Support by Recoup ") + * @returns Formatted email address with display name (e.g., "Support by Recoup ") * @throws Error if no email ending with the inbound domain is found in either array */ export function getFromWithName(toEmails: string[], ccEmails: string[] = []): string { diff --git a/lib/emails/isTestEmail.ts b/lib/emails/isTestEmail.ts index 8a05a3525..1bf2cecdb 100644 --- a/lib/emails/isTestEmail.ts +++ b/lib/emails/isTestEmail.ts @@ -1,4 +1,4 @@ // Returns true if the email is a test email export const isTestEmail = (email: string): boolean => { - return email === "sweetmantech@gmail.com" || email === "sidney@recoupable.com"; + return email === "sweetmantech@gmail.com" || email === "sidney@recoupable.dev"; }; diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index 006bd0028..8eac17e53 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -7,7 +7,7 @@ import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; * Handler for POST /api/emails. * * Sends an email to the explicit recipients in the request body via Resend - * (from `Agent by Recoup `), reusing the same + * (from `Agent by Recoup `), reusing the same * `processAndSendEmail` domain function as the `send_email` MCP tool. * Account-scoped: requires authentication via x-api-key or Authorization Bearer. * Body validation, auth, and the recipient restriction all live in diff --git a/lib/sandbox/__tests__/resolveGitUser.test.ts b/lib/sandbox/__tests__/resolveGitUser.test.ts index 04aef07f5..5b52c05f7 100644 --- a/lib/sandbox/__tests__/resolveGitUser.test.ts +++ b/lib/sandbox/__tests__/resolveGitUser.test.ts @@ -53,7 +53,7 @@ describe("resolveGitUser", () => { const gitUser = await resolveGitUser(ACCOUNT_ID); expect(gitUser.name).toBe("Ada Lovelace"); - expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.com`); + expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.dev`); }); it("falls back on both fields when nothing is on file", async () => { @@ -64,7 +64,7 @@ describe("resolveGitUser", () => { expect(gitUser).toEqual({ name: `recoupable-${ACCOUNT_ID.slice(0, 8)}`, - email: `${ACCOUNT_ID}@users.noreply.recoupable.com`, + email: `${ACCOUNT_ID}@users.noreply.recoupable.dev`, }); }); @@ -78,6 +78,6 @@ describe("resolveGitUser", () => { const gitUser = await resolveGitUser(ACCOUNT_ID); - expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.com`); + expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.dev`); }); }); diff --git a/lib/sandbox/resolveGitUser.ts b/lib/sandbox/resolveGitUser.ts index 12d3d027f..7e7efc47e 100644 --- a/lib/sandbox/resolveGitUser.ts +++ b/lib/sandbox/resolveGitUser.ts @@ -33,7 +33,7 @@ export async function resolveGitUser(accountId: string): Promise { const emailRow = emails.find(row => typeof row.email === "string" && row.email.length > 0); const name = account?.name?.trim() || `recoupable-${accountId.slice(0, 8)}`; - const email = emailRow?.email ?? `${accountId}@users.noreply.recoupable.com`; + const email = emailRow?.email ?? `${accountId}@users.noreply.recoupable.dev`; return { name, email }; } From 4290c72f8787d774b869a22448ab7358719f1807 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 2 Jul 2026 10:09:47 -0500 Subject: [PATCH 25/27] feat(apify): optional posts depth on social scrape endpoints (#741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `posts` parameter (integer, 1-100, default: legacy single-item snapshot) to POST /api/socials/{id}/scrape (query param) and POST /api/artist/socials/scrape (body field), threaded through scrapeProfileUrl/scrapeProfileUrlBatch into the platform actor inputs: - Twitter (apidojo/twitter-scraper-lite): maxItems = posts ?? 1 — returns the last N tweets with engagement metrics incl. viewCount (impressions) - YouTube (streamers/youtube-scraper): maxResults = posts ?? 1 and maxResultsShorts = posts ?? 0 — a requested depth includes Shorts, which the legacy snapshot explicitly excluded - Other platforms accept and ignore the param (Instagram profile scrapes already bundle latestPosts) Omitting posts produces the exact same actor inputs as before, so existing profile-snapshot callers and their Apify cost profile are unchanged. Implements recoupable/chat#1836; contract documented in recoupable/docs#258. Co-authored-by: Claude Fable 5 --- lib/apify/__tests__/scrapeProfileUrl.test.ts | 6 +- .../__tests__/scrapeProfileUrlBatch.test.ts | 35 +++++++++++ lib/apify/scrapeProfileUrl.ts | 8 ++- lib/apify/scrapeProfileUrlBatch.ts | 5 +- .../startTwitterProfileScraping.test.ts | 36 +++++++++++ .../twitter/startTwitterProfileScraping.ts | 7 ++- .../startYoutubeProfileScraping.test.ts | 44 +++++++++++++ .../youtube/startYoutubeProfileScraping.ts | 8 ++- .../postArtistSocialsScrapeHandler.test.ts | 63 +++++++++++++++++++ .../validateArtistSocialsScrapeBody.test.ts | 36 +++++++++++ lib/artist/postArtistSocialsScrapeHandler.ts | 1 + lib/artist/validateArtistSocialsScrapeBody.ts | 6 ++ .../__tests__/postSocialScrapeHandler.test.ts | 11 ++++ .../validatePostSocialScrapeRequest.test.ts | 25 ++++++++ lib/socials/postSocialScrapeHandler.ts | 6 +- .../validatePostSocialScrapeRequest.ts | 13 +++- 16 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 lib/apify/__tests__/scrapeProfileUrlBatch.test.ts create mode 100644 lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts create mode 100644 lib/apify/youtube/__tests__/startYoutubeProfileScraping.test.ts create mode 100644 lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts create mode 100644 lib/artist/__tests__/validateArtistSocialsScrapeBody.test.ts diff --git a/lib/apify/__tests__/scrapeProfileUrl.test.ts b/lib/apify/__tests__/scrapeProfileUrl.test.ts index 8ea6e96a8..e78bc3f1e 100644 --- a/lib/apify/__tests__/scrapeProfileUrl.test.ts +++ b/lib/apify/__tests__/scrapeProfileUrl.test.ts @@ -26,9 +26,13 @@ beforeEach(() => { describe("scrapeProfileUrl", () => { it("dispatches linkedin.com to the LinkedIn scraper", async () => { const r = await scrapeProfileUrl("https://www.linkedin.com/in/drew-thurlow", "drew-thurlow"); - expect(li).toHaveBeenCalledWith("drew-thurlow"); + expect(li).toHaveBeenCalledWith("drew-thurlow", undefined); expect(r).toMatchObject({ supported: true, runId: "li-run", datasetId: "li-ds" }); }); + it("forwards posts to the platform scraper", async () => { + await scrapeProfileUrl("https://www.linkedin.com/in/drew-thurlow", "drew-thurlow", 20); + expect(li).toHaveBeenCalledWith("drew-thurlow", 20); + }); it("returns null for an unsupported platform (spotify)", async () => { const r = await scrapeProfileUrl("https://open.spotify.com/artist/x", "x"); expect(r).toBeNull(); diff --git a/lib/apify/__tests__/scrapeProfileUrlBatch.test.ts b/lib/apify/__tests__/scrapeProfileUrlBatch.test.ts new file mode 100644 index 000000000..df92bc9fe --- /dev/null +++ b/lib/apify/__tests__/scrapeProfileUrlBatch.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { scrapeProfileUrlBatch } from "@/lib/apify/scrapeProfileUrlBatch"; +import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; + +vi.mock("@/lib/apify/scrapeProfileUrl", () => ({ scrapeProfileUrl: vi.fn() })); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(scrapeProfileUrl).mockResolvedValue({ + runId: "r1", + datasetId: "d1", + error: null, + supported: true, + }); +}); + +describe("scrapeProfileUrlBatch", () => { + it("forwards posts to every scrapeProfileUrl call", async () => { + await scrapeProfileUrlBatch( + [ + { profileUrl: "https://x.com/a", username: "a" }, + { profileUrl: "https://youtube.com/@b", username: "b" }, + ], + 20, + ); + expect(scrapeProfileUrl).toHaveBeenCalledWith("https://x.com/a", "a", 20); + expect(scrapeProfileUrl).toHaveBeenCalledWith("https://youtube.com/@b", "b", 20); + }); + + it("passes posts as undefined when omitted (legacy behavior)", async () => { + await scrapeProfileUrlBatch([{ profileUrl: "https://x.com/a", username: "a" }]); + expect(scrapeProfileUrl).toHaveBeenCalledWith("https://x.com/a", "a", undefined); + }); +}); diff --git a/lib/apify/scrapeProfileUrl.ts b/lib/apify/scrapeProfileUrl.ts index 6d30a930a..10af0009d 100644 --- a/lib/apify/scrapeProfileUrl.ts +++ b/lib/apify/scrapeProfileUrl.ts @@ -7,7 +7,10 @@ import startFacebookProfileScraping from "@/lib/apify/facebook/startFacebookProf import startLinkedinProfileScraping from "@/lib/apify/linkedin/startLinkedinProfileScraping"; import { getUsernameFromProfileUrl } from "@/lib/socials/getUsernameFromProfileUrl"; -type ScrapeRunner = (handle: string) => Promise<{ +type ScrapeRunner = ( + handle: string, + posts?: number, +) => Promise<{ runId: string; datasetId: string; error?: string; @@ -61,6 +64,7 @@ const PLATFORM_SCRAPERS: Array<{ export const scrapeProfileUrl = async ( profileUrl: string | null | undefined, username: string, + posts?: number, ): Promise => { if (!profileUrl) { return null; @@ -76,7 +80,7 @@ export const scrapeProfileUrl = async ( const finalUsername = username || getUsernameFromProfileUrl(profileUrl); try { - const result = await platform.scraper(finalUsername); + const result = await platform.scraper(finalUsername, posts); if (!result) { return { diff --git a/lib/apify/scrapeProfileUrlBatch.ts b/lib/apify/scrapeProfileUrlBatch.ts index ff4ccb90a..5b73ea7b3 100644 --- a/lib/apify/scrapeProfileUrlBatch.ts +++ b/lib/apify/scrapeProfileUrlBatch.ts @@ -7,9 +7,12 @@ type ScrapeProfileUrlBatchInput = { export const scrapeProfileUrlBatch = async ( inputs: ScrapeProfileUrlBatchInput[], + posts?: number, ): Promise => { const results = await Promise.all( - inputs.map(({ profileUrl, username }) => scrapeProfileUrl(profileUrl ?? null, username ?? "")), + inputs.map(({ profileUrl, username }) => + scrapeProfileUrl(profileUrl ?? null, username ?? "", posts), + ), ); return results diff --git a/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts b/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts new file mode 100644 index 000000000..325e1f484 --- /dev/null +++ b/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import apifyClient from "@/lib/apify/client"; +import startTwitterProfileScraping from "@/lib/apify/twitter/startTwitterProfileScraping"; + +const start = vi.fn(); +vi.mock("@/lib/apify/client", () => ({ default: { actor: vi.fn(() => ({ start })) } })); + +beforeEach(() => { + vi.clearAllMocks(); + start.mockResolvedValue({ id: "run-1", defaultDatasetId: "ds-1", status: "RUNNING" }); +}); + +describe("startTwitterProfileScraping", () => { + it("defaults to the legacy single-item snapshot (maxItems: 1) when posts is omitted", async () => { + const r = await startTwitterProfileScraping("sweetman_eth"); + expect(apifyClient.actor).toHaveBeenCalledWith("apidojo/twitter-scraper-lite"); + expect(start).toHaveBeenCalledWith( + { twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 1 }, + { webhooks: expect.any(Array) }, + ); + expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" }); + }); + + it("passes posts through as maxItems", async () => { + await startTwitterProfileScraping("sweetman_eth", 20); + expect(start).toHaveBeenCalledWith( + { twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 20 }, + { webhooks: expect.any(Array) }, + ); + }); + + it("throws on an empty handle", async () => { + await expect(startTwitterProfileScraping(" ")).rejects.toThrow(/Invalid Twitter handle/); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/apify/twitter/startTwitterProfileScraping.ts b/lib/apify/twitter/startTwitterProfileScraping.ts index 52f288038..16821355d 100644 --- a/lib/apify/twitter/startTwitterProfileScraping.ts +++ b/lib/apify/twitter/startTwitterProfileScraping.ts @@ -2,7 +2,10 @@ import apifyClient from "@/lib/apify/client"; import { getApifyWebhooks } from "@/lib/apify/getApifyWebhooks"; import { ApifyRunInfo } from "@/lib/apify/types"; -const startTwitterProfileScraping = async (handle: string): Promise => { +const startTwitterProfileScraping = async ( + handle: string, + posts?: number, +): Promise => { const cleanHandle = handle.trim(); if (!cleanHandle) { @@ -12,7 +15,7 @@ const startTwitterProfileScraping = async (handle: string): Promise ({ default: { actor: vi.fn(() => ({ start })) } })); + +beforeEach(() => { + vi.clearAllMocks(); + start.mockResolvedValue({ id: "run-1", defaultDatasetId: "ds-1", status: "RUNNING" }); +}); + +describe("startYoutubeProfileScraping", () => { + it("defaults to the legacy snapshot (maxResults: 1, Shorts excluded) when posts is omitted", async () => { + const r = await startYoutubeProfileScraping("@mycowtf"); + expect(apifyClient.actor).toHaveBeenCalledWith("streamers/youtube-scraper"); + expect(start).toHaveBeenCalledWith( + expect.objectContaining({ + startUrls: [{ url: "https://www.youtube.com/@mycowtf" }], + maxResults: 1, + maxResultsShorts: 0, + }), + { webhooks: expect.any(Array) }, + ); + expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" }); + }); + + it("passes posts through as maxResults AND maxResultsShorts (Shorts included)", async () => { + await startYoutubeProfileScraping("mycowtf", 20); + expect(start).toHaveBeenCalledWith( + expect.objectContaining({ + startUrls: [{ url: "https://www.youtube.com/@mycowtf" }], + maxResults: 20, + maxResultsShorts: 20, + }), + { webhooks: expect.any(Array) }, + ); + }); + + it("throws on an empty handle", async () => { + await expect(startYoutubeProfileScraping(" ")).rejects.toThrow(/Invalid YouTube handle/); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/apify/youtube/startYoutubeProfileScraping.ts b/lib/apify/youtube/startYoutubeProfileScraping.ts index f5a7324b5..a7f222bea 100644 --- a/lib/apify/youtube/startYoutubeProfileScraping.ts +++ b/lib/apify/youtube/startYoutubeProfileScraping.ts @@ -24,7 +24,10 @@ const DEFAULT_INPUT = { subtitlesFormat: "srt", }; -const startYoutubeProfileScraping = async (handle: string): Promise => { +const startYoutubeProfileScraping = async ( + handle: string, + posts?: number, +): Promise => { const cleanHandle = handle.trim().replace(/^@/, ""); if (!cleanHandle) { @@ -36,6 +39,9 @@ const startYoutubeProfileScraping = async (handle: string): Promise ({ scrapeProfileUrlBatch: vi.fn() })); +vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({ + selectAccountSocials: vi.fn(), +})); +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); + +const ARTIST_ID = "660e8400-e29b-41d4-a716-446655440000"; + +const makeRequest = (body: unknown) => + new NextRequest("http://localhost/api/artist/socials/scrape", { + method: "POST", + body: JSON.stringify(body), + }); + +describe("postArtistSocialsScrapeHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(selectAccountSocials).mockResolvedValue([ + { social: { profile_url: "https://x.com/a", username: "a" } } as never, + ]); + vi.mocked(scrapeProfileUrlBatch).mockResolvedValue([ + { runId: "r1", datasetId: "d1", error: null }, + ]); + }); + + it("returns 400 when artist_account_id is missing", async () => { + const res = await postArtistSocialsScrapeHandler(makeRequest({})); + expect(res.status).toBe(400); + expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); + }); + + it("scrapes without a posts depth by default (legacy behavior)", async () => { + const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })); + expect(res.status).toBe(200); + expect(scrapeProfileUrlBatch).toHaveBeenCalledWith( + [{ profileUrl: "https://x.com/a", username: "a" }], + undefined, + ); + }); + + it("forwards posts to scrapeProfileUrlBatch", async () => { + await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID, posts: 20 })); + expect(scrapeProfileUrlBatch).toHaveBeenCalledWith( + [{ profileUrl: "https://x.com/a", username: "a" }], + 20, + ); + }); + + it("returns [] when the artist has no socials", async () => { + vi.mocked(selectAccountSocials).mockResolvedValue([]); + const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/artist/__tests__/validateArtistSocialsScrapeBody.test.ts b/lib/artist/__tests__/validateArtistSocialsScrapeBody.test.ts new file mode 100644 index 000000000..fb431553d --- /dev/null +++ b/lib/artist/__tests__/validateArtistSocialsScrapeBody.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +import { validateArtistSocialsScrapeBody } from "../validateArtistSocialsScrapeBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); + +const ARTIST_ID = "660e8400-e29b-41d4-a716-446655440000"; + +describe("validateArtistSocialsScrapeBody", () => { + it("returns 400 when artist_account_id is missing", () => { + const res = validateArtistSocialsScrapeBody({}) as NextResponse; + expect(res.status).toBe(400); + }); + + it("accepts a body without posts (legacy behavior)", () => { + expect(validateArtistSocialsScrapeBody({ artist_account_id: ARTIST_ID })).toEqual({ + artist_account_id: ARTIST_ID, + }); + }); + + it("accepts a valid posts field", () => { + expect(validateArtistSocialsScrapeBody({ artist_account_id: ARTIST_ID, posts: 20 })).toEqual({ + artist_account_id: ARTIST_ID, + posts: 20, + }); + }); + + it.each([[0], [101], [1.5], ["20"]])("returns 400 for invalid posts %s", posts => { + const res = validateArtistSocialsScrapeBody({ + artist_account_id: ARTIST_ID, + posts, + }) as NextResponse; + expect(res.status).toBe(400); + }); +}); diff --git a/lib/artist/postArtistSocialsScrapeHandler.ts b/lib/artist/postArtistSocialsScrapeHandler.ts index c144ce1a4..24242971f 100644 --- a/lib/artist/postArtistSocialsScrapeHandler.ts +++ b/lib/artist/postArtistSocialsScrapeHandler.ts @@ -36,6 +36,7 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom profileUrl: social.social?.profile_url, username: social.social?.username, })), + validatedBody.posts, ); return NextResponse.json(results, { diff --git a/lib/artist/validateArtistSocialsScrapeBody.ts b/lib/artist/validateArtistSocialsScrapeBody.ts index a6d5cf8e7..099d3d682 100644 --- a/lib/artist/validateArtistSocialsScrapeBody.ts +++ b/lib/artist/validateArtistSocialsScrapeBody.ts @@ -4,6 +4,12 @@ import { z } from "zod"; export const artistSocialsScrapeBodySchema = z.object({ artist_account_id: z.string().min(1, "artist_account_id body parameter is required"), + posts: z + .number() + .int("posts must be an integer") + .min(1, "posts must be between 1 and 100") + .max(100, "posts must be between 1 and 100") + .optional(), }); export type ArtistSocialsScrapeBody = z.infer; diff --git a/lib/socials/__tests__/postSocialScrapeHandler.test.ts b/lib/socials/__tests__/postSocialScrapeHandler.test.ts index 4458ceb05..54ce9b307 100644 --- a/lib/socials/__tests__/postSocialScrapeHandler.test.ts +++ b/lib/socials/__tests__/postSocialScrapeHandler.test.ts @@ -45,6 +45,17 @@ describe("postSocialScrapeHandler", () => { const res = await postSocialScrapeHandler(request, SOCIAL_ID); expect(res.status).toBe(200); expect(await res.json()).toEqual({ runId: "r1", datasetId: "d1" }); + expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, undefined); + }); + + it("forwards validated posts to scrapeProfileUrl", async () => { + vi.mocked(validatePostSocialScrapeRequest).mockResolvedValue({ + social_id: SOCIAL_ID, + posts: 20, + }); + vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never); + await postSocialScrapeHandler(request, SOCIAL_ID); + expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, 20); }); it("returns 400 when platform unsupported (scrapeProfileUrl returns null)", async () => { diff --git a/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts b/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts index e42c6c4ef..d74d2c8d8 100644 --- a/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts +++ b/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts @@ -73,9 +73,34 @@ describe("validatePostSocialScrapeRequest", () => { it("returns validated payload when caller has access to an owning artist", async () => { expect(await validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).toEqual({ social_id: SOCIAL_ID, + posts: undefined, }); }); + it("parses a valid posts query param", async () => { + const req = new NextRequest(`http://localhost/api/socials/${SOCIAL_ID}/scrape?posts=20`, { + method: "POST", + headers: { "x-api-key": "k" }, + }); + expect(await validatePostSocialScrapeRequest(req, SOCIAL_ID)).toEqual({ + social_id: SOCIAL_ID, + posts: 20, + }); + }); + + it.each([["0"], ["101"], ["abc"], ["1.5"]])( + "returns 400 for invalid posts query param %s", + async posts => { + const req = new NextRequest( + `http://localhost/api/socials/${SOCIAL_ID}/scrape?posts=${posts}`, + { method: "POST", headers: { "x-api-key": "k" } }, + ); + const res = (await validatePostSocialScrapeRequest(req, SOCIAL_ID)) as NextResponse; + expect(res.status).toBe(400); + expect(validateAuthContext).not.toHaveBeenCalled(); + }, + ); + it("propagates DB error from selectAccountSocials (fails closed as 500)", async () => { vi.mocked(selectAccountSocials).mockRejectedValue(new Error("db blew up")); await expect(validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).rejects.toThrow( diff --git a/lib/socials/postSocialScrapeHandler.ts b/lib/socials/postSocialScrapeHandler.ts index 0e2ebb149..7489d6700 100644 --- a/lib/socials/postSocialScrapeHandler.ts +++ b/lib/socials/postSocialScrapeHandler.ts @@ -30,7 +30,11 @@ export async function postSocialScrapeHandler( ); } - const scrapeResult = await scrapeProfileUrl(social.profile_url, social.username); + const scrapeResult = await scrapeProfileUrl( + social.profile_url, + social.username, + validated.posts, + ); if (scrapeResult) { if (scrapeResult.error) { diff --git a/lib/socials/validatePostSocialScrapeRequest.ts b/lib/socials/validatePostSocialScrapeRequest.ts index 9081f9105..8feefe62c 100644 --- a/lib/socials/validatePostSocialScrapeRequest.ts +++ b/lib/socials/validatePostSocialScrapeRequest.ts @@ -9,6 +9,12 @@ import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess export const postSocialScrapeParamsSchema = z.object({ social_id: z.string().uuid("social_id must be a valid UUID"), + posts: z.coerce + .number() + .int("posts must be an integer") + .min(1, "posts must be between 1 and 100") + .max(100, "posts must be between 1 and 100") + .optional(), }); export type PostSocialScrapeParams = z.infer; @@ -17,7 +23,10 @@ export async function validatePostSocialScrapeRequest( request: NextRequest, id: string, ): Promise { - const parsed = postSocialScrapeParamsSchema.safeParse({ social_id: id }); + const parsed = postSocialScrapeParamsSchema.safeParse({ + social_id: id, + posts: request.nextUrl.searchParams.get("posts") ?? undefined, + }); if (!parsed.success) { const issue = parsed.error.issues[0]; return validationErrorResponse(issue.message, issue.path); @@ -51,5 +60,5 @@ export async function validatePostSocialScrapeRequest( return errorResponse("Unauthorized social scrape attempt", 403); } - return { social_id }; + return { social_id, posts: parsed.data.posts }; } From cbb4d7a71e760896eb6368e466e0f1905a96bad9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 3 Jul 2026 13:59:36 -0500 Subject: [PATCH 26/27] feat(apify): account-scope GET /api/apify/runs/{runId} (chat#1840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents that start a scrape with an account key could never read the result: the results endpoint was admin-only because Apify run ids carry no account scope (7/7 Forbidden polls in the issue evidence). Now the scrape-start handler records run ownership in apify_scraper_runs, and the results validator authorizes owner-or-admin: - admin: any run id, unchanged (backend pollers keep working) - owner: runs it started via POST /api/socials/{id}/scrape - foreign run (non-admin): 403 Forbidden - no recorded owner (non-admin): 404 Run not found — covers pre-tracking runs and runs started from other endpoints, without leaking existence Ownership insert logs-and-continues on failure so a mapping miss only degrades that run to admin-only polling, never fails the scrape. Depends on recoupable/database#39 (creates apify_scraper_runs). Contract: recoupable/docs#262. types/database.types.ts block is hand-added to match the generator output; re-generate after the migration applies. TDD: validator suite rewritten RED->GREEN (auth propagation, 400, admin passthrough, owner passthrough, foreign 403, unknown 404, null-lookup 404); scrape handler gains ownership-recording tests. Full lib/apify+lib/socials+lib/supabase suites green (279 tests); tsc adds no new errors over main; eslint clean. Co-Authored-By: Claude Fable 5 --- .../validateGetScraperResultsRequest.test.ts | 60 +++++++++++++++---- lib/apify/validateGetScraperResultsRequest.ts | 41 ++++++++++--- .../__tests__/postSocialScrapeHandler.test.ts | 20 +++++++ lib/socials/postSocialScrapeHandler.ts | 11 ++++ .../insertApifyScraperRun.ts | 22 +++++++ .../selectApifyScraperRuns.ts | 29 +++++++++ types/database.types.ts | 21 +++++++ 7 files changed, 185 insertions(+), 19 deletions(-) create mode 100644 lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts create mode 100644 lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts diff --git a/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts b/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts index 46101820d..3919ceb98 100644 --- a/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts +++ b/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts @@ -2,44 +2,80 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateGetScraperResultsRequest } from "../validateGetScraperResultsRequest"; -import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; +import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns"; -vi.mock("@/lib/admins/validateAdminAuth", () => ({ validateAdminAuth: vi.fn() })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); +vi.mock("@/lib/admins/checkIsAdmin", () => ({ checkIsAdmin: vi.fn() })); +vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns", () => ({ + selectApifyScraperRuns: vi.fn(), +})); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); const RUN_ID = "run_abc_123"; const ACCOUNT_ID = "660e8400-e29b-41d4-a716-446655440000"; +const OTHER_ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000"; const authResult = { accountId: ACCOUNT_ID, authToken: "t", orgId: null }; const makeRequest = (path = `/api/apify/runs/${RUN_ID}`) => new NextRequest(`http://localhost${path}`, { headers: { "x-api-key": "k" } }); +const ownedRun = { run_id: RUN_ID, account_id: ACCOUNT_ID, social_id: null, created_at: "" }; + describe("validateGetScraperResultsRequest", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(validateAdminAuth).mockResolvedValue(authResult); + vi.mocked(validateAuthContext).mockResolvedValue(authResult); + vi.mocked(checkIsAdmin).mockResolvedValue(false); + vi.mocked(selectApifyScraperRuns).mockResolvedValue([ownedRun as never]); }); it("propagates auth error before checking runId", async () => { const err = NextResponse.json({}, { status: 401 }); - vi.mocked(validateAdminAuth).mockResolvedValue(err); + vi.mocked(validateAuthContext).mockResolvedValue(err); expect(await validateGetScraperResultsRequest(makeRequest(), "")).toBe(err); }); - it("propagates 403 from non-admin auth", async () => { - const err = NextResponse.json({}, { status: 403 }); - vi.mocked(validateAdminAuth).mockResolvedValue(err); - expect(await validateGetScraperResultsRequest(makeRequest(), RUN_ID)).toBe(err); - }); - - it("returns 400 when runId is empty after admin auth succeeds", async () => { + it("returns 400 when runId is empty after auth succeeds", async () => { const res = (await validateGetScraperResultsRequest(makeRequest(), "")) as NextResponse; expect(res.status).toBe(400); }); - it("returns validated runId on happy path", async () => { + it("passes an admin through without an ownership lookup", async () => { + vi.mocked(checkIsAdmin).mockResolvedValue(true); + expect(await validateGetScraperResultsRequest(makeRequest(), RUN_ID)).toEqual({ + runId: RUN_ID, + }); + expect(selectApifyScraperRuns).not.toHaveBeenCalled(); + }); + + it("passes the owning account through (non-admin)", async () => { expect(await validateGetScraperResultsRequest(makeRequest(), RUN_ID)).toEqual({ runId: RUN_ID, }); + expect(selectApifyScraperRuns).toHaveBeenCalledWith({ runId: RUN_ID }); + }); + + it("returns 403 Forbidden when the run belongs to a different account", async () => { + vi.mocked(selectApifyScraperRuns).mockResolvedValue([ + { ...ownedRun, account_id: OTHER_ACCOUNT_ID } as never, + ]); + const res = (await validateGetScraperResultsRequest(makeRequest(), RUN_ID)) as NextResponse; + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ status: "error", message: "Forbidden" }); + }); + + it("returns 404 when the run has no recorded owner (non-admin)", async () => { + vi.mocked(selectApifyScraperRuns).mockResolvedValue([]); + const res = (await validateGetScraperResultsRequest(makeRequest(), RUN_ID)) as NextResponse; + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ status: "error", message: "Run not found" }); + }); + + it("returns 404 when the ownership lookup fails (null result, non-admin)", async () => { + vi.mocked(selectApifyScraperRuns).mockResolvedValue(null); + const res = (await validateGetScraperResultsRequest(makeRequest(), RUN_ID)) as NextResponse; + expect(res.status).toBe(404); }); }); diff --git a/lib/apify/validateGetScraperResultsRequest.ts b/lib/apify/validateGetScraperResultsRequest.ts index dd2acc8db..de61ce13a 100644 --- a/lib/apify/validateGetScraperResultsRequest.ts +++ b/lib/apify/validateGetScraperResultsRequest.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; +import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns"; export const getScraperResultsParamsSchema = z.object({ runId: z.string().min(1), @@ -10,18 +12,21 @@ export const getScraperResultsParamsSchema = z.object({ export type GetScraperResultsParams = z.infer; /** - * Authenticates the request as an admin, then validates the runId path param. + * Authenticates the request, then validates the runId path param. * - * Admin-only: Apify run identifiers are not account-scoped, and the poller - * is a backend-only caller (tasks). + * Owner-or-admin (recoupable/chat#1840): admins may poll any run; other + * accounts only runs they started (matched via apify_scraper_runs, written + * at scrape start). Runs with no recorded owner — pre-tracking runs, or + * runs started outside POST /api/socials/{id}/scrape — return 404 for + * non-admin callers rather than leaking whether the run id exists. */ export async function validateGetScraperResultsRequest( request: NextRequest, runId: string, ): Promise { - const authResult = await validateAdminAuth(request); - if (authResult instanceof NextResponse) { - return authResult; + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; } const parsed = getScraperResultsParamsSchema.safeParse({ runId }); @@ -32,5 +37,27 @@ export async function validateGetScraperResultsRequest( ); } + const isAdmin = await checkIsAdmin(auth.accountId); + if (isAdmin) { + return parsed.data; + } + + const runs = await selectApifyScraperRuns({ runId: parsed.data.runId }); + const run = runs?.[0]; + + if (!run) { + return NextResponse.json( + { status: "error", message: "Run not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (run.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", message: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + return parsed.data; } diff --git a/lib/socials/__tests__/postSocialScrapeHandler.test.ts b/lib/socials/__tests__/postSocialScrapeHandler.test.ts index c645aa2c2..318cc6fff 100644 --- a/lib/socials/__tests__/postSocialScrapeHandler.test.ts +++ b/lib/socials/__tests__/postSocialScrapeHandler.test.ts @@ -6,6 +6,7 @@ import { validatePostSocialScrapeRequest } from "../validatePostSocialScrapeRequ import { selectSocials } from "@/lib/supabase/socials/selectSocials"; import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; import { deductSocialScrapeCredits } from "../deductSocialScrapeCredits"; +import { insertApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/insertApifyScraperRun"; vi.mock("../validatePostSocialScrapeRequest", () => ({ validatePostSocialScrapeRequest: vi.fn(), @@ -13,6 +14,9 @@ vi.mock("../validatePostSocialScrapeRequest", () => ({ vi.mock("@/lib/supabase/socials/selectSocials", () => ({ selectSocials: vi.fn() })); vi.mock("@/lib/apify/scrapeProfileUrl", () => ({ scrapeProfileUrl: vi.fn() })); vi.mock("../deductSocialScrapeCredits", () => ({ deductSocialScrapeCredits: vi.fn() })); +vi.mock("@/lib/supabase/apify_scraper_runs/insertApifyScraperRun", () => ({ + insertApifyScraperRun: vi.fn(), +})); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); const SOCIAL_ID = "550e8400-e29b-41d4-a716-446655440000"; @@ -64,6 +68,22 @@ describe("postSocialScrapeHandler", () => { expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); }); + it("records run ownership on a successful start", async () => { + vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never); + await postSocialScrapeHandler(request, SOCIAL_ID); + expect(insertApifyScraperRun).toHaveBeenCalledWith({ + run_id: "r1", + account_id: ACCOUNT_ID, + social_id: SOCIAL_ID, + }); + }); + + it("does not record ownership when the scrape fails to start", async () => { + vi.mocked(scrapeProfileUrl).mockResolvedValue({ error: "boom" } as never); + await postSocialScrapeHandler(request, SOCIAL_ID); + expect(insertApifyScraperRun).not.toHaveBeenCalled(); + }); + it("does not deduct credits when the scrape fails to start", async () => { vi.mocked(scrapeProfileUrl).mockResolvedValue({ error: "boom" } as never); await postSocialScrapeHandler(request, SOCIAL_ID); diff --git a/lib/socials/postSocialScrapeHandler.ts b/lib/socials/postSocialScrapeHandler.ts index 71080bdcd..243911351 100644 --- a/lib/socials/postSocialScrapeHandler.ts +++ b/lib/socials/postSocialScrapeHandler.ts @@ -5,6 +5,7 @@ import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; import { validatePostSocialScrapeRequest } from "@/lib/socials/validatePostSocialScrapeRequest"; import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits"; import { getSocialScrapeCreditCost } from "@/lib/socials/getSocialScrapeCreditCost"; +import { insertApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/insertApifyScraperRun"; export async function postSocialScrapeHandler( request: NextRequest, @@ -55,6 +56,16 @@ export async function postSocialScrapeHandler( validated.account_id, getSocialScrapeCreditCost(validated.posts), ); + // Ownership map for GET /api/apify/runs/{runId} (chat#1840). The + // insert logs-and-returns-null on failure, so a mapping miss only + // degrades this run to admin-only polling — never fails the scrape. + if (scrapeResult.runId) { + await insertApifyScraperRun({ + run_id: scrapeResult.runId, + account_id: validated.account_id, + social_id: validated.social_id, + }); + } return NextResponse.json( { runId: scrapeResult.runId, diff --git a/lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts b/lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts new file mode 100644 index 000000000..b7b471ef4 --- /dev/null +++ b/lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts @@ -0,0 +1,22 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Records which account started an Apify scraper run (recoupable/chat#1840). + * Read by the GET /api/apify/runs/{runId} validator to authorize the owner. + * + * @param run - The run ownership row to insert + * @returns The inserted row, or null on error + */ +export async function insertApifyScraperRun( + run: TablesInsert<"apify_scraper_runs">, +): Promise | null> { + const { data, error } = await supabase.from("apify_scraper_runs").insert(run).select().single(); + + if (error) { + console.error("Error inserting apify_scraper_runs:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts b/lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts new file mode 100644 index 000000000..b94776f65 --- /dev/null +++ b/lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts @@ -0,0 +1,29 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select apify_scraper_runs rows, optionally filtered by run id. + * + * @param runId - Apify run id to filter by + * @returns Matching rows, or null on error + */ +export async function selectApifyScraperRuns({ + runId, +}: { + runId?: string; +} = {}): Promise[] | null> { + let query = supabase.from("apify_scraper_runs").select("*"); + + if (runId) { + query = query.eq("run_id", runId); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching apify_scraper_runs:", error); + return null; + } + + return data || []; +} diff --git a/types/database.types.ts b/types/database.types.ts index 45da666d6..dac850619 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -737,6 +737,27 @@ export type Database = { }; Relationships: []; }; + apify_scraper_runs: { + Row: { + account_id: string; + created_at: string; + run_id: string; + social_id: string | null; + }; + Insert: { + account_id: string; + created_at?: string; + run_id: string; + social_id?: string | null; + }; + Update: { + account_id?: string; + created_at?: string; + run_id?: string; + social_id?: string | null; + }; + Relationships: []; + }; app_store_link_clicked: { Row: { clientId: string | null; From 6563b83d52be3cab7336c5f7a305dec13a851cf8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 3 Jul 2026 14:46:50 -0500 Subject: [PATCH 27/27] rework: any authenticated caller can poll scraper runs (chat#1840 decision) Design decision 2026-07-03 (supersedes the owner-or-admin draft): scrape datasets are public social content and Apify run ids are unguessable, so possession of a runId plus any valid API key / Bearer token is sufficient. validateAdminAuth -> validateAuthContext; drops the apify_scraper_runs ownership map, the scrape-start insert, and the 403/404 paths. recoupable/database#39 is closed unmerged. Co-Authored-By: Claude Fable 5 --- .../validateGetScraperResultsRequest.test.ts | 44 +------------------ lib/apify/validateGetScraperResultsRequest.ts | 37 +++------------- .../__tests__/postSocialScrapeHandler.test.ts | 20 --------- lib/socials/postSocialScrapeHandler.ts | 11 ----- .../insertApifyScraperRun.ts | 22 ---------- .../selectApifyScraperRuns.ts | 29 ------------ types/database.types.ts | 21 --------- 7 files changed, 8 insertions(+), 176 deletions(-) delete mode 100644 lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts delete mode 100644 lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts diff --git a/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts b/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts index 3919ceb98..e638341b3 100644 --- a/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts +++ b/lib/apify/__tests__/validateGetScraperResultsRequest.test.ts @@ -3,32 +3,21 @@ import { NextRequest, NextResponse } from "next/server"; import { validateGetScraperResultsRequest } from "../validateGetScraperResultsRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; -import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); -vi.mock("@/lib/admins/checkIsAdmin", () => ({ checkIsAdmin: vi.fn() })); -vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns", () => ({ - selectApifyScraperRuns: vi.fn(), -})); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); const RUN_ID = "run_abc_123"; const ACCOUNT_ID = "660e8400-e29b-41d4-a716-446655440000"; -const OTHER_ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000"; const authResult = { accountId: ACCOUNT_ID, authToken: "t", orgId: null }; const makeRequest = (path = `/api/apify/runs/${RUN_ID}`) => new NextRequest(`http://localhost${path}`, { headers: { "x-api-key": "k" } }); -const ownedRun = { run_id: RUN_ID, account_id: ACCOUNT_ID, social_id: null, created_at: "" }; - describe("validateGetScraperResultsRequest", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(validateAuthContext).mockResolvedValue(authResult); - vi.mocked(checkIsAdmin).mockResolvedValue(false); - vi.mocked(selectApifyScraperRuns).mockResolvedValue([ownedRun as never]); }); it("propagates auth error before checking runId", async () => { @@ -42,40 +31,9 @@ describe("validateGetScraperResultsRequest", () => { expect(res.status).toBe(400); }); - it("passes an admin through without an ownership lookup", async () => { - vi.mocked(checkIsAdmin).mockResolvedValue(true); - expect(await validateGetScraperResultsRequest(makeRequest(), RUN_ID)).toEqual({ - runId: RUN_ID, - }); - expect(selectApifyScraperRuns).not.toHaveBeenCalled(); - }); - - it("passes the owning account through (non-admin)", async () => { + it("passes any authenticated caller through — no admin or ownership check", async () => { expect(await validateGetScraperResultsRequest(makeRequest(), RUN_ID)).toEqual({ runId: RUN_ID, }); - expect(selectApifyScraperRuns).toHaveBeenCalledWith({ runId: RUN_ID }); - }); - - it("returns 403 Forbidden when the run belongs to a different account", async () => { - vi.mocked(selectApifyScraperRuns).mockResolvedValue([ - { ...ownedRun, account_id: OTHER_ACCOUNT_ID } as never, - ]); - const res = (await validateGetScraperResultsRequest(makeRequest(), RUN_ID)) as NextResponse; - expect(res.status).toBe(403); - expect(await res.json()).toEqual({ status: "error", message: "Forbidden" }); - }); - - it("returns 404 when the run has no recorded owner (non-admin)", async () => { - vi.mocked(selectApifyScraperRuns).mockResolvedValue([]); - const res = (await validateGetScraperResultsRequest(makeRequest(), RUN_ID)) as NextResponse; - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ status: "error", message: "Run not found" }); - }); - - it("returns 404 when the ownership lookup fails (null result, non-admin)", async () => { - vi.mocked(selectApifyScraperRuns).mockResolvedValue(null); - const res = (await validateGetScraperResultsRequest(makeRequest(), RUN_ID)) as NextResponse; - expect(res.status).toBe(404); }); }); diff --git a/lib/apify/validateGetScraperResultsRequest.ts b/lib/apify/validateGetScraperResultsRequest.ts index de61ce13a..07caec131 100644 --- a/lib/apify/validateGetScraperResultsRequest.ts +++ b/lib/apify/validateGetScraperResultsRequest.ts @@ -2,8 +2,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; -import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns"; export const getScraperResultsParamsSchema = z.object({ runId: z.string().min(1), @@ -12,13 +10,14 @@ export const getScraperResultsParamsSchema = z.object({ export type GetScraperResultsParams = z.infer; /** - * Authenticates the request, then validates the runId path param. + * Authenticates the request (any valid API key or Bearer token), then + * validates the runId path param. * - * Owner-or-admin (recoupable/chat#1840): admins may poll any run; other - * accounts only runs they started (matched via apify_scraper_runs, written - * at scrape start). Runs with no recorded owner — pre-tracking runs, or - * runs started outside POST /api/socials/{id}/scrape — return 404 for - * non-admin callers rather than leaking whether the run id exists. + * Deliberately not owner-scoped (chat#1840 decision, 2026-07-03): scrape + * datasets are public social content and Apify run ids are unguessable, + * so possession of a runId plus any valid credential is sufficient. + * Authentication still gates the endpoint so anonymous callers can't use + * it as a free Apify proxy. */ export async function validateGetScraperResultsRequest( request: NextRequest, @@ -37,27 +36,5 @@ export async function validateGetScraperResultsRequest( ); } - const isAdmin = await checkIsAdmin(auth.accountId); - if (isAdmin) { - return parsed.data; - } - - const runs = await selectApifyScraperRuns({ runId: parsed.data.runId }); - const run = runs?.[0]; - - if (!run) { - return NextResponse.json( - { status: "error", message: "Run not found" }, - { status: 404, headers: getCorsHeaders() }, - ); - } - - if (run.account_id !== auth.accountId) { - return NextResponse.json( - { status: "error", message: "Forbidden" }, - { status: 403, headers: getCorsHeaders() }, - ); - } - return parsed.data; } diff --git a/lib/socials/__tests__/postSocialScrapeHandler.test.ts b/lib/socials/__tests__/postSocialScrapeHandler.test.ts index 318cc6fff..c645aa2c2 100644 --- a/lib/socials/__tests__/postSocialScrapeHandler.test.ts +++ b/lib/socials/__tests__/postSocialScrapeHandler.test.ts @@ -6,7 +6,6 @@ import { validatePostSocialScrapeRequest } from "../validatePostSocialScrapeRequ import { selectSocials } from "@/lib/supabase/socials/selectSocials"; import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; import { deductSocialScrapeCredits } from "../deductSocialScrapeCredits"; -import { insertApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/insertApifyScraperRun"; vi.mock("../validatePostSocialScrapeRequest", () => ({ validatePostSocialScrapeRequest: vi.fn(), @@ -14,9 +13,6 @@ vi.mock("../validatePostSocialScrapeRequest", () => ({ vi.mock("@/lib/supabase/socials/selectSocials", () => ({ selectSocials: vi.fn() })); vi.mock("@/lib/apify/scrapeProfileUrl", () => ({ scrapeProfileUrl: vi.fn() })); vi.mock("../deductSocialScrapeCredits", () => ({ deductSocialScrapeCredits: vi.fn() })); -vi.mock("@/lib/supabase/apify_scraper_runs/insertApifyScraperRun", () => ({ - insertApifyScraperRun: vi.fn(), -})); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); const SOCIAL_ID = "550e8400-e29b-41d4-a716-446655440000"; @@ -68,22 +64,6 @@ describe("postSocialScrapeHandler", () => { expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); }); - it("records run ownership on a successful start", async () => { - vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never); - await postSocialScrapeHandler(request, SOCIAL_ID); - expect(insertApifyScraperRun).toHaveBeenCalledWith({ - run_id: "r1", - account_id: ACCOUNT_ID, - social_id: SOCIAL_ID, - }); - }); - - it("does not record ownership when the scrape fails to start", async () => { - vi.mocked(scrapeProfileUrl).mockResolvedValue({ error: "boom" } as never); - await postSocialScrapeHandler(request, SOCIAL_ID); - expect(insertApifyScraperRun).not.toHaveBeenCalled(); - }); - it("does not deduct credits when the scrape fails to start", async () => { vi.mocked(scrapeProfileUrl).mockResolvedValue({ error: "boom" } as never); await postSocialScrapeHandler(request, SOCIAL_ID); diff --git a/lib/socials/postSocialScrapeHandler.ts b/lib/socials/postSocialScrapeHandler.ts index 243911351..71080bdcd 100644 --- a/lib/socials/postSocialScrapeHandler.ts +++ b/lib/socials/postSocialScrapeHandler.ts @@ -5,7 +5,6 @@ import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; import { validatePostSocialScrapeRequest } from "@/lib/socials/validatePostSocialScrapeRequest"; import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits"; import { getSocialScrapeCreditCost } from "@/lib/socials/getSocialScrapeCreditCost"; -import { insertApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/insertApifyScraperRun"; export async function postSocialScrapeHandler( request: NextRequest, @@ -56,16 +55,6 @@ export async function postSocialScrapeHandler( validated.account_id, getSocialScrapeCreditCost(validated.posts), ); - // Ownership map for GET /api/apify/runs/{runId} (chat#1840). The - // insert logs-and-returns-null on failure, so a mapping miss only - // degrades this run to admin-only polling — never fails the scrape. - if (scrapeResult.runId) { - await insertApifyScraperRun({ - run_id: scrapeResult.runId, - account_id: validated.account_id, - social_id: validated.social_id, - }); - } return NextResponse.json( { runId: scrapeResult.runId, diff --git a/lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts b/lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts deleted file mode 100644 index b7b471ef4..000000000 --- a/lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts +++ /dev/null @@ -1,22 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { Tables, TablesInsert } from "@/types/database.types"; - -/** - * Records which account started an Apify scraper run (recoupable/chat#1840). - * Read by the GET /api/apify/runs/{runId} validator to authorize the owner. - * - * @param run - The run ownership row to insert - * @returns The inserted row, or null on error - */ -export async function insertApifyScraperRun( - run: TablesInsert<"apify_scraper_runs">, -): Promise | null> { - const { data, error } = await supabase.from("apify_scraper_runs").insert(run).select().single(); - - if (error) { - console.error("Error inserting apify_scraper_runs:", error); - return null; - } - - return data; -} diff --git a/lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts b/lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts deleted file mode 100644 index b94776f65..000000000 --- a/lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts +++ /dev/null @@ -1,29 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Select apify_scraper_runs rows, optionally filtered by run id. - * - * @param runId - Apify run id to filter by - * @returns Matching rows, or null on error - */ -export async function selectApifyScraperRuns({ - runId, -}: { - runId?: string; -} = {}): Promise[] | null> { - let query = supabase.from("apify_scraper_runs").select("*"); - - if (runId) { - query = query.eq("run_id", runId); - } - - const { data, error } = await query; - - if (error) { - console.error("Error fetching apify_scraper_runs:", error); - return null; - } - - return data || []; -} diff --git a/types/database.types.ts b/types/database.types.ts index dac850619..45da666d6 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -737,27 +737,6 @@ export type Database = { }; Relationships: []; }; - apify_scraper_runs: { - Row: { - account_id: string; - created_at: string; - run_id: string; - social_id: string | null; - }; - Insert: { - account_id: string; - created_at?: string; - run_id: string; - social_id?: string | null; - }; - Update: { - account_id?: string; - created_at?: string; - run_id?: string; - social_id?: string | null; - }; - Relationships: []; - }; app_store_link_clicked: { Row: { clientId: string | null;