Skip to content
Merged
49 changes: 0 additions & 49 deletions app/api/chat/generate/route.ts

This file was deleted.

42 changes: 42 additions & 0 deletions app/api/chat/runs/[runId]/route.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +27 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tighten the JSDoc auth contract to match validateAuthContext().

The docblock states Authentication: x-api-key header required (Line 27), but the downstream handleChatRunStatus uses validateAuthContext(), which accepts both the x-api-key header and Authorization: Bearer tokens. Self-documenting code should not understate the supported auth surface—callers may wrongly assume Bearer tokens are rejected here.

📝 Suggested doc tweak
- * Authentication: x-api-key header required.
+ * Authentication: requires `x-api-key` header or `Authorization: Bearer` token
+ * (validated via `validateAuthContext()`).

As per coding guidelines: "Always use validateAuthContext() for authentication in API routes; it supports both x-api-key header and Authorization: Bearer token authentication".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* 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.
* Authentication: requires `x-api-key` header or `Authorization: Bearer` token
* (validated via `validateAuthContext()`).
*
* `@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.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/chat/runs/`[runId]/route.ts around lines 27 - 32, Update the JSDoc in
the route handler for handleChatRunStatus so the authentication contract matches
validateAuthContext(): it should mention both x-api-key and Authorization:
Bearer token support instead of only x-api-key. Keep the rest of the docblock
aligned with the actual auth behavior used by the request handling path.

Source: Coding guidelines

*/
export async function GET(
request: NextRequest,
ctx: { params: Promise<{ runId: string }> },
): Promise<Response> {
const { runId } = await ctx.params;
return handleChatRunStatus(request, runId);
}

export const dynamic = "force-dynamic";
55 changes: 55 additions & 0 deletions app/api/chat/runs/route.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +28 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the route JSDoc aligned with the actual contract.

This block still says x-api-key is required and documents topic, but the validator path now supports validateAuthContext() auth and no longer accepts topic. Since this comment is the API contract for the route, stale docs will send callers down the wrong path. As per coding guidelines, API routes should have accurate JSDoc comments and use validateAuthContext() for authentication.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/chat/runs/route.ts` around lines 28 - 37, Update the JSDoc for the
chat runs route so it matches the current request contract used by the route
handler and validator path. In the comments near the route entrypoint, replace
the stale x-api-key-only auth description with the actual authentication flow
via validateAuthContext(), and remove the topic field from the documented
request body since it is no longer accepted. Keep the docs aligned with the
symbols handling the route contract, especially validateAuthContext() and the
route handler in app/api/chat/runs/route.ts.

Source: Coding guidelines

*
* 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<Response> {
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";
35 changes: 35 additions & 0 deletions app/lib/workflows/__tests__/runAgentWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions app/lib/workflows/deleteEphemeralKeyStep.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
"use step";
try {
const { error } = await deleteApiKey(keyId);
Comment on lines +15 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify deleteApiKey enforces account + ephemeral-only predicates.
# Expected: delete query filters by id, account, and an ephemeral marker.
fd -a 'deleteApiKey.ts' . --exec sh -c 'echo "### $1"; sed -n "1,200p" "$1"' sh {}
rg -n -C3 '\bdeleteApiKey\s*\(' .

Repository: recoupable/api

Length of output: 2791


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the delete workflow and related key lifecycle code for scoping and ephemeral markers.
sed -n '1,220p' app/lib/workflows/deleteEphemeralKeyStep.ts
printf '\n---\n'
sed -n '1,220p' lib/supabase/account_api_keys/deleteApiKey.ts
printf '\n---\n'
fd -a 'mint*Key*.ts' lib app . | head -50
printf '\n---\n'
fd -a '*account_api_keys*' lib app . | head -50
printf '\n---\n'
rg -n -C4 'expires_at|ephemeral|account_api_keys|deleteApiKey\(' lib app

Repository: recoupable/api

Length of output: 1909


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the minting and cleanup flow for the ephemeral key.
rg -n -C4 'deleteEphemeralKeyStep|runAgentWorkflow|mint.*Key|expires_at|getApiKeyAccountId|account_api_keys' app lib
printf '\n---\n'
sed -n '1,260p' lib/chat/handleStartChatRun.ts
printf '\n---\n'
fd -a '*getApiKeyAccountId*.ts' lib app . | xargs -r -I{} sh -c 'echo "### {}"; sed -n "1,220p" "{}"'

Repository: recoupable/api

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find every call site for the workflow step and the raw delete helper.
rg -n -C3 'deleteEphemeralKeyStep\(|deleteApiKey\(' app lib --glob '!**/__tests__/**'

Repository: recoupable/api

Length of output: 3035


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find every call site for the workflow step and the raw delete helper.
rg -n -C3 'deleteEphemeralKeyStep\(|deleteApiKey\(' app lib --glob '!**/__tests__/**'

Repository: recoupable/api

Length of output: 3035


Scope the delete to the minted ephemeral key. deleteApiKey(keyId) only filters by id, so a bad keyId can revoke a non-ephemeral row. Thread accountId through this step and add an ephemeral-only predicate (expires_at IS NOT NULL or the ephemeral:chat-generate name) to the delete query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/workflows/deleteEphemeralKeyStep.ts` around lines 15 - 18, Scope the
deletion in deleteEphemeralKeyStep so it only targets the minted ephemeral key:
thread accountId through deleteEphemeralKeyStep and into deleteApiKey, and
update the delete query to require an ephemeral-only predicate such as
expires_at IS NOT NULL or the ephemeral:chat-generate name in addition to
matching the key identifier. Use the deleteApiKey path to ensure the row
selection is constrained by both account ownership and ephemeral key criteria
before deleting.

if (error) {
console.error(`[deleteEphemeralKeyStep] failed to delete key ${keyId}:`, error);
}
} catch (error) {
console.error(`[deleteEphemeralKeyStep] unhandled error deleting key ${keyId}:`, error);
}
}
13 changes: 11 additions & 2 deletions app/lib/workflows/runAgentWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -151,11 +152,19 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise<vo
// stream-end. Mirrors open-agents'
// `Promise.all([clearActiveStream, sendFinish.then(closeStream)])`.
//
// `Promise.all` is safe because both helpers swallow their own
// errors — a failure in one doesn't cancel the other.
// A third cleanup step runs only for headless `/api/chat/runs` runs:
// 3) `deleteEphemeralKeyStep` — revoke the per-run, account-scoped
// `recoup_sk_…` key minted for the sandbox the moment the run ends.
// The key's ~15m TTL is only the backstop if this delete is missed.
//
// `Promise.all` is safe because all helpers swallow their own errors —
// a failure in one doesn't cancel the others.
await Promise.all([
clearChatActiveStream(input.chatId, workflowRunId),
closeChatStream(writable),
...(input.agentContext.ephemeralKeyId
? [deleteEphemeralKeyStep(input.agentContext.ephemeralKeyId)]
: []),
]);
}
}
9 changes: 9 additions & 0 deletions lib/agent/tools/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ export type AgentContext = {
* Empty / undefined when the sandbox has no `skills/` directory.
*/
skills?: SkillMetadata[];
/**
* Row id of an ephemeral, account-scoped `recoup_sk_…` key minted for a
* headless run (`/api/chat/runs`, recoupable/chat#1813). When set, the
* workflow deletes the key in its `finally` cleanup so the credential is
* revoked the moment the run ends (the key's ~15m `expires_at` TTL is the
* defense-in-depth backstop if that delete is missed). The interactive
* caller forwards a Privy JWT and never sets this.
*/
ephemeralKeyId?: string;
/**
* Main agent's language model. Tools read this via `getMainModel`.
* Set per-step by `runAgentStep` (not part of the durable input).
Expand Down
Loading
Loading