Re-point POST /api/chat/generate onto runAgentWorkflow + ephemeral key (chat#1813)#704
Conversation
…813)
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
More reviews will be available in 24 minutes and 26 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (4)
📒 Files selected for processing (10)
📝 WalkthroughWalkthroughThe PR removes the legacy ChangesChat runs cutover
Sequence Diagram(s)sequenceDiagram
participant Client
participant RunsRoute as POST /api/chat/runs
participant Handler as handleStartChatRun
participant Validate as validateGenerateRequest
participant Provision as provisionGenerateSession
participant Mint as mintEphemeralAccountKey
participant Workflow as runAgentWorkflow
participant StatusRoute as GET /api/chat/runs/{runId}
participant StatusHandler as handleChatRunStatus
Client->>RunsRoute: POST /api/chat/runs
RunsRoute->>Handler: handleStartChatRun(request)
Handler->>Validate: validate request body and auth
Handler->>Provision: create session, chat, sandbox
Handler->>Mint: mint ephemeral account key
Handler->>Workflow: start workflow with keyId
Handler-->>Client: 202 runId, chatId, sessionId
Client->>StatusRoute: GET /api/chat/runs/{runId}
StatusRoute->>StatusHandler: handleChatRunStatus(request, runId)
StatusHandler-->>Client: normalized run status
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
7 issues found across 14 files
Confidence score: 3/5
- In
lib/chat/generate/validateGenerateRequest.ts,messagesare type-cast toUIMessage[]without normalization and blankpromptstrings pass validation, so malformed/empty inputs can get accepted and fail later in generation flows with user-visible errors—add strict message shape validation plusprompt.trim()presence checks before merging. - In
lib/chat/handleChatGenerate.ts, the ephemeral-key cleanup path does not surface DB delete failures, which can leave compromised run keys usable until expiry with no operational signal—treat delete failures as actionable (log/alert and fail or retry) before merge. - In
lib/chat/generate/provisionGenerateSession.ts, provisioned sandboxes are not hooked into lifecycle kick scheduling and mid-flight provisioning failures have no rollback, so sessions can become unsupervised and orphaned resources can accumulate—register generate sessions with lifecycle scheduling and add transactional/compensating cleanup for partial failures. lib/chat/generate/__tests__/validateGenerateRequest.test.tscurrently passes via UUID schema failure instead of the intended prompt/messages exclusivity path, andlib/chat/handleChatGenerate.tsreturns a non-standard 500 body; this can hide regressions and break contract-based clients/tests—fix the test fixture to hit the intended branch and align 500 text to "Internal server error".
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/chat/handleChatGenerate.ts">
<violation number="1" location="lib/chat/handleChatGenerate.ts:72">
P2: Ephemeral-key cleanup path does not detect DB delete failures. This can leave compromised run keys valid until expiry without any error signal.</violation>
</file>
<file name="lib/chat/generate/validateGenerateRequest.ts">
<violation number="1" location="lib/chat/generate/validateGenerateRequest.ts:80">
P1: `messages` are cast to `UIMessage[]` without normalization/validation, so non-UIMessage payloads can pass validation and break later in the workflow.</violation>
</file>
<file name="lib/chat/generate/provisionGenerateSession.ts">
<violation number="1" location="lib/chat/generate/provisionGenerateSession.ts:67">
P2: Provisioning has no rollback/cleanup on mid-flight failure after creating session/chat. Failed starts can accumulate orphan sessions/chats and potentially a persistent sandbox.</violation>
<violation number="2" location="lib/chat/generate/provisionGenerateSession.ts:88">
P2: Provisioned generate sandboxes are not registered with lifecycle kick scheduling. This can leave scheduled-generation sessions unsupervised for idle auto-pause behavior.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| const uiMessages: UIMessage[] = hasPrompt | ||
| ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: prompt! }] }] | ||
| : (messages as UIMessage[]); |
There was a problem hiding this comment.
P1: messages are cast to UIMessage[] without normalization/validation, so non-UIMessage payloads can pass validation and break later in the workflow.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/generate/validateGenerateRequest.ts, line 80:
<comment>`messages` are cast to `UIMessage[]` without normalization/validation, so non-UIMessage payloads can pass validation and break later in the workflow.</comment>
<file context>
@@ -0,0 +1,90 @@
+
+ const uiMessages: UIMessage[] = hasPrompt
+ ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: prompt! }] }]
+ : (messages as UIMessage[]);
+
+ return {
</file context>
| // 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 { |
There was a problem hiding this comment.
P2: Ephemeral-key cleanup path does not detect DB delete failures. This can leave compromised run keys valid until expiry without any error signal.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/handleChatGenerate.ts, line 72:
<comment>Ephemeral-key cleanup path does not detect DB delete failures. This can leave compromised run keys valid until expiry without any error signal.</comment>
<file context>
@@ -1,77 +1,81 @@
+ // 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) {
</file context>
✅ Preview test — re-point verified end-to-end on the durable workflowPreview: MethodCalled the endpoint with a real Results (explicit, non-null)
What this provesThe scheduled path now runs on the same Test session/chat/messages + the test key were deleted afterward (all 0 remaining). |
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) <noreply@anthropic.com>
Contract update — now returns
|
There was a problem hiding this comment.
0 issues found across 3 files (changes from recent commits).
Requires human review: Auto-approval blocked by 7 unresolved issues from previous reviews.
Re-trigger cubic
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) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 10 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/chat/handleStartChatRun.ts">
<violation number="1" location="lib/chat/handleStartChatRun.ts:76">
P2: 202 Location points to a run-status endpoint that is not implemented yet, so clients following the header will hit 404. Either implement `GET /api/chat/runs/{runId}` now or omit the Location header until it exists.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| { runId: run.runId, chatId: provisioned.chat.id, sessionId: provisioned.session.id }, | ||
| { | ||
| status: 202, | ||
| headers: { ...getCorsHeaders(), Location: `/api/chat/runs/${run.runId}` }, |
There was a problem hiding this comment.
P2: 202 Location points to a run-status endpoint that is not implemented yet, so clients following the header will hit 404. Either implement GET /api/chat/runs/{runId} now or omit the Location header until it exists.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/handleStartChatRun.ts, line 76:
<comment>202 Location points to a run-status endpoint that is not implemented yet, so clients following the header will hit 404. Either implement `GET /api/chat/runs/{runId}` now or omit the Location header until it exists.</comment>
<file context>
@@ -66,11 +67,14 @@ export async function handleChatGenerate(request: NextRequest): Promise<Response
- { status: 202, headers: getCorsHeaders() },
+ {
+ status: 202,
+ headers: { ...getCorsHeaders(), Location: `/api/chat/runs/${run.runId}` },
+ },
);
</file context>
| headers: { ...getCorsHeaders(), Location: `/api/chat/runs/${run.runId}` }, | |
| headers: getCorsHeaders(), |
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) <noreply@anthropic.com>
There was a problem hiding this comment.
0 issues found across 1 file (changes from recent commits).
Requires human review: Auto-approval blocked by 5 unresolved issues from previous reviews.
Re-trigger cubic
/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) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (3)
lib/chat/buildRunAgentInput.ts (1)
39-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSplit agent-context construction out of
buildRunAgentInput().This helper now does repo-id derivation, org extraction, and conditional agent-context wiring in one 30+ line function. With headless-only fields like
ephemeralKeyIdbeing added, it will keep growing. Extract a dedicatedbuildAgentContexthelper so this file stays focused on composingRunAgentWorkflowInput. As per path instructions, lib functions should keep single responsibility; as per coding guidelines, flag functions longer than 20 lines.🤖 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 `@lib/chat/buildRunAgentInput.ts` around lines 39 - 73, Split the agent-context assembly out of buildRunAgentInput so the function only composes RunAgentWorkflowInput; move the sandbox, recoupOrgId, skills, recoupAccessToken, and ephemeralKeyId wiring into a new buildAgentContext helper. Keep buildRunAgentInput focused on repo parsing and top-level field mapping, and have it call the new helper for the agentContext property to reduce growth and improve single responsibility.Sources: Coding guidelines, Path instructions
lib/supabase/account_api_keys/insertApiKey.ts (1)
14-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd an explicit typed return contract.
This Supabase wrapper now owns the
expires_atinsert contract, but its return type is still inferred. Annotate it with the project’sTables<"account_api_keys">result shape so callers get a stable API.As per path instructions,
lib/supabase/**/*.tsoperations should return typed results usingTables<"table_name">.🤖 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 `@lib/supabase/account_api_keys/insertApiKey.ts` around lines 14 - 34, The insertApiKey wrapper currently relies on inferred return typing, so callers do not get the stable Supabase contract. Add an explicit return annotation on insertApiKey using the project’s Tables<"account_api_keys"> result shape for the successful data value and keep the null/error branch typed consistently. Make sure the function signature and returned object in insertApiKey clearly express the typed result instead of depending on inference.Source: Path instructions
lib/chat/generate/provisionGenerateSession.ts (1)
41-109: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftSplit this provisioning helper into focused steps.
This 69-line function combines repo setup, DB writes, sandbox lifecycle, and skill discovery. Extract focused helpers so failure boundaries and tests stay maintainable.
As per coding guidelines, flag functions longer than 20 lines and keep functions small and focused. As per path instructions,
lib/**/*.tsfunctions should have single responsibility and stay under 50 lines.🤖 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 `@lib/chat/generate/provisionGenerateSession.ts` around lines 41 - 109, `provisionGenerateSession` is doing too many unrelated jobs in one place, so break it into smaller single-purpose helpers to match the function-size guidelines. Extract the repo provisioning, session/chat creation, sandbox connection, session activation, and skill/working-directory discovery into focused functions called from `provisionGenerateSession`, keeping the top-level function as orchestration only. Use the existing symbols like `ensurePersonalRepo`, `insertSession`, `insertChat`, `connectSandbox`, `updateSession`, and the skill-discovery block to guide the split, and preserve the current failure handling at each step.Sources: Coding guidelines, Path instructions
🤖 Prompt for all review comments with 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.
Inline comments:
In `@app/api/chat/runs/route.ts`:
- Around line 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.
In `@app/lib/workflows/deleteEphemeralKeyStep.ts`:
- Around line 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.
In `@lib/chat/generate/provisionGenerateSession.ts`:
- Around line 53-88: The provisioning flow in provisionGenerateSession currently
throws after insertSession succeeds without undoing earlier side effects, so add
compensating cleanup around the insertChat, connectSandbox, and updateSession
steps. If any later step fails, delete the created chat/session rows and tear
down any sandbox created by connectSandbox before rethrowing, using the existing
helpers in provisionGenerateSession and the session/chat/sandbox identifiers to
locate the resources.
- Around line 58-62: The chat row is being created with a hardcoded title
instead of the caller-provided value, which can cause mismatched session labels.
Update provisionGenerateSession’s insertChat call to use the requested title
from the function’s configuration/object arguments rather than "Scheduled
generation", and keep the title wiring consistent with the surrounding
session/chat creation flow.
In `@lib/chat/generate/validateGenerateRequest.ts`:
- Around line 20-25: Remove accountId from the body schema in generateBodySchema
and keep this helper focused only on request body validation. Derive tenancy
exclusively in validateAuthContext() instead of allowing a second account source
here, and update the generate request flow so account selection happens after
auth resolution. If needed, split any mixed responsibilities in
validateGenerateRequest() so body parsing, auth context lookup, and message
normalization are handled by separate helpers with clear single responsibility.
- Around line 20-27: The generateBodySchema currently accepts messages as
z.any(), so malformed arrays bypass request validation and fail later after the
workflow starts. Replace the permissive messages field in
validateGenerateRequest.ts with a real Zod schema for the expected message
structure and use that schema in generateBodySchema so the request is rejected
at the boundary before any cast to UIMessage[] occurs.
In `@lib/chat/handleStartChatRun.ts`:
- Around line 75-80: The 202 response from handleStartChatRun currently
advertises a Location for the run status endpoint even though the corresponding
route is not shipped yet. Update the NextResponse.json call in
handleStartChatRun so it no longer sets the Location header unless the
/api/chat/runs status handler is included in this PR; keep the response body
unchanged and only reference the runId, chatId, and sessionId once the status
resource actually exists.
- Around line 44-94: Rollback the provisioned chat/session/sandbox when
`handleStartChatRun` fails after `provisionGenerateSession()` succeeds but
before `start(runAgentWorkflow, ...)` completes. Move the partial-start flow
into a dedicated helper around `provisionGenerateSession`,
`mintEphemeralAccountKey`, and `start` so the catch path can compensate for all
created resources, not just `deleteApiKey`. Ensure the helper explicitly cleans
up the provisioned session/sandbox on startup failure and keep
`handleStartChatRun` focused and under the line-length guideline.
In `@lib/keys/mintEphemeralAccountKey.ts`:
- Around line 19-45: The mintEphemeralAccountKey helper should not trust ttlMs
directly, since invalid values can produce bad expiration timestamps or weaken
the short-lived key behavior. Add TTL validation in mintEphemeralAccountKey and
extract the expiry computation into a small dedicated helper so the main
function stays focused and under the size guideline; use the existing
DEFAULT_EPHEMERAL_KEY_TTL_MS, expiresAt, and insertApiKey flow as the
integration points.
---
Nitpick comments:
In `@lib/chat/buildRunAgentInput.ts`:
- Around line 39-73: Split the agent-context assembly out of buildRunAgentInput
so the function only composes RunAgentWorkflowInput; move the sandbox,
recoupOrgId, skills, recoupAccessToken, and ephemeralKeyId wiring into a new
buildAgentContext helper. Keep buildRunAgentInput focused on repo parsing and
top-level field mapping, and have it call the new helper for the agentContext
property to reduce growth and improve single responsibility.
In `@lib/chat/generate/provisionGenerateSession.ts`:
- Around line 41-109: `provisionGenerateSession` is doing too many unrelated
jobs in one place, so break it into smaller single-purpose helpers to match the
function-size guidelines. Extract the repo provisioning, session/chat creation,
sandbox connection, session activation, and skill/working-directory discovery
into focused functions called from `provisionGenerateSession`, keeping the
top-level function as orchestration only. Use the existing symbols like
`ensurePersonalRepo`, `insertSession`, `insertChat`, `connectSandbox`,
`updateSession`, and the skill-discovery block to guide the split, and preserve
the current failure handling at each step.
In `@lib/supabase/account_api_keys/insertApiKey.ts`:
- Around line 14-34: The insertApiKey wrapper currently relies on inferred
return typing, so callers do not get the stable Supabase contract. Add an
explicit return annotation on insertApiKey using the project’s
Tables<"account_api_keys"> result shape for the successful data value and keep
the null/error branch typed consistently. Make sure the function signature and
returned object in insertApiKey clearly express the typed result instead of
depending on inference.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f8f33af2-d7dc-4559-8a97-b5bf03b8158a
⛔ Files ignored due to path filters (5)
app/lib/workflows/__tests__/runAgentWorkflow.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included byapp/**lib/chat/__tests__/handleChatGenerate.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/chat/__tests__/handleStartChatRun.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/chat/generate/__tests__/validateGenerateRequest.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/keys/__tests__/mintEphemeralAccountKey.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (12)
app/api/chat/generate/route.tsapp/api/chat/runs/route.tsapp/lib/workflows/deleteEphemeralKeyStep.tsapp/lib/workflows/runAgentWorkflow.tslib/agent/tools/AgentContext.tslib/chat/buildRunAgentInput.tslib/chat/generate/provisionGenerateSession.tslib/chat/generate/validateGenerateRequest.tslib/chat/handleChatGenerate.tslib/chat/handleStartChatRun.tslib/keys/mintEphemeralAccountKey.tslib/supabase/account_api_keys/insertApiKey.ts
💤 Files with no reviewable changes (2)
- lib/chat/handleChatGenerate.ts
- app/api/chat/generate/route.ts
| * 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) |
There was a problem hiding this comment.
📐 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
| export async function deleteEphemeralKeyStep(keyId: string): Promise<void> { | ||
| "use step"; | ||
| try { | ||
| const { error } = await deleteApiKey(keyId); |
There was a problem hiding this comment.
🔒 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 appRepository: 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.
| const session = await insertSession( | ||
| buildSessionInsertRow({ accountId, title, cloneUrl, artistId }), | ||
| ); | ||
| if (!session) throw new Error("Failed to create session"); | ||
|
|
||
| const chat = await insertChat({ | ||
| id: generateUUID(), | ||
| session_id: session.id, | ||
| title: "Scheduled generation", | ||
| }); | ||
| if (!chat) throw new Error("Failed to create chat"); | ||
|
|
||
| const sandboxName = getSessionSandboxName(session.id); | ||
| const gitUser = await resolveGitUser(accountId); | ||
| const sandbox = await connectSandbox({ | ||
| state: { type: "vercel", sandboxName, source: { repo: cloneUrl, prebuilt: false } }, | ||
| options: { | ||
| timeout: SANDBOX_TIMEOUT_MS, | ||
| ports: [3000], | ||
| githubToken: getServiceGithubToken(), | ||
| gitUser, | ||
| persistent: true, | ||
| resume: true, | ||
| createIfMissing: true, | ||
| }, | ||
| }); | ||
|
|
||
| const sandboxState = sandbox.getState() as Json; | ||
| const updated = await updateSession(session.id, { | ||
| sandbox_state: sandboxState, | ||
| lifecycle_version: session.lifecycle_version + 1, | ||
| ...buildActiveLifecycleUpdate(sandboxState), | ||
| snapshot_url: null, | ||
| snapshot_created_at: null, | ||
| }); | ||
| if (!updated) throw new Error("Failed to activate session sandbox"); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Add compensating cleanup for partial provisioning failures.
After Line 53 succeeds, later failures in chat insert, sandbox connect, or session activation throw without reverting created rows or the persistent sandbox. This can leave orphaned headless sessions/chats/sandboxes after 5xx responses or retries.
🤖 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 `@lib/chat/generate/provisionGenerateSession.ts` around lines 53 - 88, The
provisioning flow in provisionGenerateSession currently throws after
insertSession succeeds without undoing earlier side effects, so add compensating
cleanup around the insertChat, connectSandbox, and updateSession steps. If any
later step fails, delete the created chat/session rows and tear down any sandbox
created by connectSandbox before rethrowing, using the existing helpers in
provisionGenerateSession and the session/chat/sandbox identifiers to locate the
resources.
| const chat = await insertChat({ | ||
| id: generateUUID(), | ||
| session_id: session.id, | ||
| title: "Scheduled generation", | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use the requested title for the chat row.
Line 61 ignores the title argument and hardcodes "Scheduled generation", so future callers can create sessions and chats with mismatched labels.
Proposed fix
const chat = await insertChat({
id: generateUUID(),
session_id: session.id,
- title: "Scheduled generation",
+ title,
});As per coding guidelines, use configuration objects instead of hardcoded values.
📝 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.
| const chat = await insertChat({ | |
| id: generateUUID(), | |
| session_id: session.id, | |
| title: "Scheduled generation", | |
| }); | |
| const chat = await insertChat({ | |
| id: generateUUID(), | |
| session_id: session.id, | |
| title, | |
| }); |
🤖 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 `@lib/chat/generate/provisionGenerateSession.ts` around lines 58 - 62, The chat
row is being created with a hardcoded title instead of the caller-provided
value, which can cause mismatched session labels. Update
provisionGenerateSession’s insertChat call to use the requested title from the
function’s configuration/object arguments rather than "Scheduled generation",
and keep the title wiring consistent with the surrounding session/chat creation
flow.
Source: Coding guidelines
| export const generateBodySchema = 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(), |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Derive the account exclusively from auth and split that concern out of this validator.
Publishing accountId in the body schema gives this route two sources of truth for tenancy, and this helper is already doing body parsing, auth resolution, and message normalization in one place. Keep account selection inside validateAuthContext() and leave this file as a pure body validator. As per coding guidelines, always derive the account ID from authentication and flag long functions; as per path instructions, validation/domain helpers should keep single responsibility.
Also applies to: 71-87
🤖 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 `@lib/chat/generate/validateGenerateRequest.ts` around lines 20 - 25, Remove
accountId from the body schema in generateBodySchema and keep this helper
focused only on request body validation. Derive tenancy exclusively in
validateAuthContext() instead of allowing a second account source here, and
update the generate request flow so account selection happens after auth
resolution. If needed, split any mixed responsibilities in
validateGenerateRequest() so body parsing, auth context lookup, and message
normalization are handled by separate helpers with clear single responsibility.
Sources: Coding guidelines, Path instructions
| export const generateBodySchema = 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(), | ||
| }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Validate messages structurally instead of accepting z.any().
Any array currently passes here and then gets cast to UIMessage[]. A malformed payload will bypass the 400 path, fail later inside the workflow, and can leave you with invalid persisted input after the async run has already started. Define a real Zod schema for the message shape at this boundary. As per coding guidelines, API input should be parsed with Zod validation; as per path instructions, validation functions should use Zod for schema validation.
🤖 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 `@lib/chat/generate/validateGenerateRequest.ts` around lines 20 - 27, The
generateBodySchema currently accepts messages as z.any(), so malformed arrays
bypass request validation and fail later after the workflow starts. Replace the
permissive messages field in validateGenerateRequest.ts with a real Zod schema
for the expected message structure and use that schema in generateBodySchema so
the request is rejected at the boundary before any cast to UIMessage[] occurs.
Sources: Coding guidelines, Path instructions
| const provisioned = await provisionGenerateSession({ | ||
| 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); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Rollback the provisioned session/sandbox when workflow startup fails.
If provisionGenerateSession() succeeds and start() throws, this catch only deletes the ephemeral key. The newly created chat/session and active sandbox are left behind even though no run exists, which leaks resources and leaves dangling state. Please move provisioning plus compensating cleanup into a dedicated helper so the whole partial-start path is covered. As per path instructions, domain functions should have proper error handling and single responsibility; as per coding guidelines, flag functions longer than 20 lines.
🤖 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 `@lib/chat/handleStartChatRun.ts` around lines 44 - 94, Rollback the
provisioned chat/session/sandbox when `handleStartChatRun` fails after
`provisionGenerateSession()` succeeds but before `start(runAgentWorkflow, ...)`
completes. Move the partial-start flow into a dedicated helper around
`provisionGenerateSession`, `mintEphemeralAccountKey`, and `start` so the catch
path can compensate for all created resources, not just `deleteApiKey`. Ensure
the helper explicitly cleans up the provisioned session/sandbox on startup
failure and keep `handleStartChatRun` focused and under the line-length
guideline.
Sources: Coding guidelines, Path instructions
| return NextResponse.json( | ||
| { runId: run.runId, chatId: provisioned.chat.id, sessionId: provisioned.session.id }, | ||
| { | ||
| status: 202, | ||
| headers: { ...getCorsHeaders(), Location: `/api/chat/runs/${run.runId}` }, | ||
| }, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Don't return a Location header for a status route that isn't shipped yet.
This response advertises /api/chat/runs/${run.runId}, but app/api/chat/runs/route.ts says the status route lands in a follow-up. That gives clients a dead pointer in the 202 contract. Either add the status handler in this PR or omit Location until the resource exists.
🤖 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 `@lib/chat/handleStartChatRun.ts` around lines 75 - 80, The 202 response from
handleStartChatRun currently advertises a Location for the run status endpoint
even though the corresponding route is not shipped yet. Update the
NextResponse.json call in handleStartChatRun so it no longer sets the Location
header unless the /api/chat/runs status handler is included in this PR; keep the
response body unchanged and only reference the runId, chatId, and sessionId once
the status resource actually exists.
| export async function mintEphemeralAccountKey( | ||
| accountId: string, | ||
| { | ||
| ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, | ||
| name = "ephemeral:chat-generate", | ||
| }: { | ||
| ttlMs?: number; | ||
| name?: string; | ||
| } = {}, | ||
| ): Promise<EphemeralAccountKey> { | ||
| 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 }; | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Validate TTLs and isolate expiry calculation.
Line 31 trusts ttlMs; negative, zero, non-finite, or oversized values can create unusable keys, throw on toISOString(), or weaken the short-lived-key guarantee. Extracting TTL validation/calculation also keeps this helper under the function-size guideline.
Proposed fix
export const DEFAULT_EPHEMERAL_KEY_TTL_MS = 15 * 60 * 1000;
+const MAX_EPHEMERAL_KEY_TTL_MS = DEFAULT_EPHEMERAL_KEY_TTL_MS;
+
+function buildEphemeralExpiresAt(ttlMs: number): string {
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > MAX_EPHEMERAL_KEY_TTL_MS) {
+ throw new Error("Invalid ephemeral key TTL");
+ }
+
+ return new Date(Date.now() + ttlMs).toISOString();
+}
export type EphemeralAccountKey = { rawKey: string; keyId: string };
@@
const rawKey = generateApiKey("recoup_sk");
const keyHash = hashApiKey(rawKey, PRIVY_PROJECT_SECRET);
- const expiresAt = new Date(Date.now() + ttlMs).toISOString();
+ const expiresAt = buildEphemeralExpiresAt(ttlMs);As per coding guidelines, flag functions longer than 20 lines and keep functions small and focused.
📝 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.
| export async function mintEphemeralAccountKey( | |
| accountId: string, | |
| { | |
| ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, | |
| name = "ephemeral:chat-generate", | |
| }: { | |
| ttlMs?: number; | |
| name?: string; | |
| } = {}, | |
| ): Promise<EphemeralAccountKey> { | |
| 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 }; | |
| } | |
| export const DEFAULT_EPHEMERAL_KEY_TTL_MS = 15 * 60 * 1000; | |
| const MAX_EPHEMERAL_KEY_TTL_MS = DEFAULT_EPHEMERAL_KEY_TTL_MS; | |
| function buildEphemeralExpiresAt(ttlMs: number): string { | |
| if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > MAX_EPHEMERAL_KEY_TTL_MS) { | |
| throw new Error("Invalid ephemeral key TTL"); | |
| } | |
| return new Date(Date.now() + ttlMs).toISOString(); | |
| } | |
| export async function mintEphemeralAccountKey( | |
| accountId: string, | |
| { | |
| ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, | |
| name = "ephemeral:chat-generate", | |
| }: { | |
| ttlMs?: number; | |
| name?: string; | |
| } = {}, | |
| ): Promise<EphemeralAccountKey> { | |
| const rawKey = generateApiKey("recoup_sk"); | |
| const keyHash = hashApiKey(rawKey, PRIVY_PROJECT_SECRET); | |
| const expiresAt = buildEphemeralExpiresAt(ttlMs); | |
| 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 }; | |
| } |
🤖 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 `@lib/keys/mintEphemeralAccountKey.ts` around lines 19 - 45, The
mintEphemeralAccountKey helper should not trust ttlMs directly, since invalid
values can produce bad expiration timestamps or weaken the short-lived key
behavior. Add TTL validation in mintEphemeralAccountKey and extract the expiry
computation into a small dedicated helper so the main function stays focused and
under the size guideline; use the existing DEFAULT_EPHEMERAL_KEY_TTL_MS,
expiresAt, and insertApiKey flow as the integration points.
Source: Coding guidelines
There was a problem hiding this comment.
0 issues found across 4 files (changes from recent commits).
Requires human review: Auto-approval blocked by 5 unresolved issues from previous reviews.
Re-trigger cubic
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) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/chat/handleChatGenerate.ts">
<violation number="1" location="lib/chat/handleChatGenerate.ts:72">
P2: Ephemeral-key cleanup path does not detect DB delete failures. This can leave compromised run keys valid until expiry without any error signal.</violation>
</file>
<file name="lib/chat/generate/validateGenerateRequest.ts">
<violation number="1" location="lib/chat/generate/validateGenerateRequest.ts:80">
P1: `messages` are cast to `UIMessage[]` without normalization/validation, so non-UIMessage payloads can pass validation and break later in the workflow.</violation>
</file>
<file name="lib/chat/handleStartChatRun.ts">
<violation number="1" location="lib/chat/handleStartChatRun.ts:76">
P2: 202 Location points to a run-status endpoint that is not implemented yet, so clients following the header will hit 404. Either implement `GET /api/chat/runs/{runId}` now or omit the Location header until it exists.</violation>
</file>
<file name="lib/chat/generate/handleChatRunStatus.ts">
<violation number="1" location="lib/chat/generate/handleChatRunStatus.ts:59">
P1: Error handling may misclassify non-404 failures as 404, masking real server/workflow errors.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| rawStatus = await getRun(runId).status; | ||
| } catch (error) { | ||
| console.error(`[handleChatRunStatus] run not found ${runId}:`, error); | ||
| return errorResponse("Run not found", 404); |
There was a problem hiding this comment.
P1: Error handling may misclassify non-404 failures as 404, masking real server/workflow errors.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/generate/handleChatRunStatus.ts, line 59:
<comment>Error handling may misclassify non-404 failures as 404, masking real server/workflow errors.</comment>
<file context>
@@ -0,0 +1,66 @@
+ rawStatus = await getRun(runId).status;
+ } catch (error) {
+ console.error(`[handleChatRunStatus] run not found ${runId}:`, error);
+ return errorResponse("Run not found", 404);
+ }
+
</file context>
✅
|
| Request | Result |
|---|---|
POST /api/chat/runs |
202 {runId: wrun_01KVXJWT…, chatId, sessionId} |
GET /api/chat/runs/{runId} (in-flight) |
200 {"runId":"…","status":"running"} |
GET …/{runId} (after completion) |
200 {"status":"completed"} |
GET …/{unknown} |
404 Run not found |
GET … (no auth) |
401 |
Run went running → completed — confirms the status normalizer + the docs enum against real workflow states. The minted ephemeral key was auto-revoked on completion (0 left). Test session/chat/messages + key deleted.
Contract note: returns { runId, status }, not the originally-documented chatId/sessionId — getRun(runId) exposes only status and there's no durable runId→chat mapping (the caller already has chatId/sessionId from the 202). Docs reconciled in docs#250. Full chatId/sessionId + per-run ownership would need a chats.last_run_id column (follow-up).
| * Normalize a Vercel Workflow run state to the documented `ChatRunStatusResponse` | ||
| * enum. `pending` is treated as running (the codebase's `RUNNING_STATUSES`). | ||
| */ | ||
| function normalizeRunStatus(raw: string): ChatRunStatus { |
There was a problem hiding this comment.
SRP - new lib file for normalizeRunStatus
There was a problem hiding this comment.
Done in d139489 — extracted normalizeRunStatus into lib/chat/generate/normalizeRunStatus.ts (one exported fn per file) with its own unit test.
There was a problem hiding this comment.
DRY - are there any duplicate code blocks introduced here which duplicate the existing /api/chat flow? If so, can the blocks be abstracted to a shared lib used in both paths instead of duplicating the code blocks?
There was a problem hiding this comment.
Good catch — yes, it duplicated the interactive flow. Extracted in d139489:
createSessionWithInitialChat(ensurePersonalRepo → insertSession → insertChat + rollback) — now shared bycreateSessionHandler(POST /api/sessions) andprovisionGenerateSession. This also fixes the headless rollback gap a bot flagged.markSessionSandboxActive(bind sandbox state + mark active) — shared bycreateSandboxHandler(POST /api/sandbox) andprovisionGenerateSession.
I left the connectSandbox call 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 omits, so unifying it would couple unrelated concerns. Behavior-preserving — full lib/sessions + lib/sandbox suites green + new unit tests.
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) <noreply@anthropic.com>
✅ Re-tested after the SRP/DRY refactor — with full run observabilityPreview commit 1. Start the run —
|
| part | input | output |
|---|---|---|
| text | — | "I'll run those three commands for you." |
tool-bash |
date -u |
Wed Jun 24 20:23:34 UTC 2026 (exit 0) |
tool-bash |
uname -a |
Linux f32dc8d2-4d4 5.10.174 #1 SMP … x86_64 GNU/Linux (exit 0) |
tool-bash |
ls -1 | wc -l |
3 (exit 0) |
| text (final) | — | "The sandbox is running on UTC time Wednesday, June 24, 2026 at 20:23:34, on a Linux x86_64 system (kernel 5.10.174) with 3 files in the working directory." |
Message metadata: modelId: anthropic/claude-haiku-4.5, step finish reasons [tool-calls, stop], totalTokens: 11818, totalMessageCost: $0.0090.
What this proves
A headless caller (x-api-key, no browser) started a durable agent run, watched it reach completed via the status endpoint, and can read the full work — tool calls, command outputs, reasoning, and final answer — from the chat by the chatId the start call returned. The agent used native in-sandbox bash tools (the unified runAgentWorkflow), and the per-run ephemeral key was auto-revoked on completion (0 left). Test session/chat/messages + key deleted afterward.
(Same flow as a scheduled report — except the report's agent would also curl recoup-api via $RECOUP_API_KEY and email the result.)
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
lib/sessions/createSessionWithInitialChat.ts (1)
32-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSplit the provisioning helper below the 20-line limit.
createSessionWithInitialChatnow handles repo provisioning, session insert, chat insert, rollback, and logging in one 35-line function. Extract the chat insert/rollback block into small private helpers so the exported orchestration stays focused. As per coding guidelines,**/*.{js,ts,tsx,jsx,py,java,cs,go,rb,php}: “Flag functions longer than 20 lines or classes with >200 lines”.🤖 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 `@lib/sessions/createSessionWithInitialChat.ts` around lines 32 - 66, The exported createSessionWithInitialChat function is doing too much and exceeds the 20-line guideline. Extract the chat insert and rollback/error-logging logic into one or more small private helpers, and keep createSessionWithInitialChat focused on orchestration of ensurePersonalRepo, insertSession, and the helper call. Preserve the existing behavior for insertChat, deleteSessionById, and the orphaned-session console.error path while reducing the main function’s size.Source: Coding guidelines
🤖 Prompt for all review comments with 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.
Inline comments:
In `@app/api/chat/runs/`[runId]/route.ts:
- Around line 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.
In `@lib/chat/generate/provisionGenerateSession.ts`:
- Around line 44-49: The headless provisioning path in
createSessionWithInitialChat is missing the workspace/org account, so it can
fall back to the wrong account for repo provisioning. Update
provisionGenerateSession to thread through the authenticated workspace account
the same way createSessionHandler does, using auth.orgId when present and
otherwise auth.accountId, and pass that value into the
createSessionWithInitialChat call so both paths behave consistently.
In `@lib/sandbox/createSandboxHandler.ts`:
- Around line 125-131: The session activation path in createSandboxHandler
ignores the possibility that markSessionSandboxActive returns null, so the
handler can still report success even when the session was not activated. Update
the logic around sessionRow, sandbox.getState, and markSessionSandboxActive to
check the helper’s return value and treat a null result as a failure: stop the
success response, surface an appropriate error, and avoid returning 200 when the
session lifecycle fields could not be bound.
---
Nitpick comments:
In `@lib/sessions/createSessionWithInitialChat.ts`:
- Around line 32-66: The exported createSessionWithInitialChat function is doing
too much and exceeds the 20-line guideline. Extract the chat insert and
rollback/error-logging logic into one or more small private helpers, and keep
createSessionWithInitialChat focused on orchestration of ensurePersonalRepo,
insertSession, and the helper call. Preserve the existing behavior for
insertChat, deleteSessionById, and the orphaned-session console.error path while
reducing the main function’s size.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b412a65a-d254-4866-be83-9844f7eb5657
⛔ Files ignored due to path filters (4)
lib/chat/generate/__tests__/handleChatRunStatus.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/chat/generate/__tests__/normalizeRunStatus.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/sandbox/__tests__/markSessionSandboxActive.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/sessions/__tests__/createSessionWithInitialChat.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (8)
app/api/chat/runs/[runId]/route.tslib/chat/generate/handleChatRunStatus.tslib/chat/generate/normalizeRunStatus.tslib/chat/generate/provisionGenerateSession.tslib/sandbox/createSandboxHandler.tslib/sandbox/markSessionSandboxActive.tslib/sessions/createSessionHandler.tslib/sessions/createSessionWithInitialChat.ts
✅ Files skipped from review due to trivial changes (3)
- lib/chat/generate/handleChatRunStatus.ts
- lib/chat/generate/normalizeRunStatus.ts
- lib/sandbox/markSessionSandboxActive.ts
| * 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. |
There was a problem hiding this comment.
📐 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.
| * 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
| const created = await createSessionWithInitialChat({ | ||
| accountId, | ||
| title, | ||
| chatTitle: "Scheduled generation", | ||
| artistId, | ||
| }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Thread the workspace account into headless provisioning.
createSessionHandler passes workspaceAccountId: auth.orgId ?? auth.accountId, but this headless path cannot pass an org/workspace account at all. For org-scoped runs, createSessionWithInitialChat will default repo provisioning to accountId, diverging from the interactive session path.
Proposed direction
export async function provisionGenerateSession({
accountId,
+ workspaceAccountId,
title,
artistId,
}: {
accountId: string;
+ workspaceAccountId?: string;
title: string;
artistId?: string;
}): Promise<ProvisionedGenerateSession> {
const created = await createSessionWithInitialChat({
accountId,
+ workspaceAccountId,
title,
chatTitle: "Scheduled generation",
artistId,
});Update the caller to pass the authenticated org/workspace account when present.
🤖 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 `@lib/chat/generate/provisionGenerateSession.ts` around lines 44 - 49, The
headless provisioning path in createSessionWithInitialChat is missing the
workspace/org account, so it can fall back to the wrong account for repo
provisioning. Update provisionGenerateSession to thread through the
authenticated workspace account the same way createSessionHandler does, using
auth.orgId when present and otherwise auth.accountId, and pass that value into
the createSessionWithInitialChat call so both paths behave consistently.
| if (sessionRow && sandbox.getState) { | ||
| const nextState = sandbox.getState() as Json; | ||
| // Match open-agents' contract: derive lifecycle fields from the | ||
| // state object's `expiresAt` (always populated by the SDK, even on | ||
| // prebuilt-snapshot paths) rather than `sandbox.expiresAt`, which | ||
| // is only set on some creation paths and was leaving | ||
| // `sandbox_expires_at: null` for org-snapshot-restored provisions — | ||
| // which the lifecycle workflow then interpreted as "no live runtime" | ||
| // and immediately wrote `lifecycle_state: "hibernated"`. | ||
| await updateSession(sessionRow.id, { | ||
| sandbox_state: nextState, | ||
| lifecycle_version: sessionRow.lifecycle_version + 1, | ||
| ...buildActiveLifecycleUpdate(nextState), | ||
| snapshot_url: null, | ||
| snapshot_created_at: null, | ||
| }); | ||
| // Bind the sandbox to the session + mark it active via the shared helper | ||
| // (also used by the headless `provisionGenerateSession`). It derives the | ||
| // lifecycle fields from the state object's `expiresAt` — always populated by | ||
| // the SDK, even on prebuilt-snapshot paths — rather than `sandbox.expiresAt`, | ||
| // which is only set on some creation paths. | ||
| await markSessionSandboxActive(sessionRow, sandbox.getState() as Json); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Handle failed session activation before returning success.
markSessionSandboxActive can return null, but this handler ignores it and still returns 200. That can leave the sandbox created while the session row is not marked active or lifecycle-bound.
Proposed fix
- await markSessionSandboxActive(sessionRow, sandbox.getState() as Json);
+ const updatedSessionRow = await markSessionSandboxActive(sessionRow, sandbox.getState() as Json);
+ if (!updatedSessionRow) {
+ return NextResponse.json(
+ { status: "error", error: "Failed to activate session sandbox" },
+ { status: 500, headers: getCorsHeaders() },
+ );
+ }
+ sessionRow = updatedSessionRow;📝 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.
| if (sessionRow && sandbox.getState) { | |
| const nextState = sandbox.getState() as Json; | |
| // Match open-agents' contract: derive lifecycle fields from the | |
| // state object's `expiresAt` (always populated by the SDK, even on | |
| // prebuilt-snapshot paths) rather than `sandbox.expiresAt`, which | |
| // is only set on some creation paths and was leaving | |
| // `sandbox_expires_at: null` for org-snapshot-restored provisions — | |
| // which the lifecycle workflow then interpreted as "no live runtime" | |
| // and immediately wrote `lifecycle_state: "hibernated"`. | |
| await updateSession(sessionRow.id, { | |
| sandbox_state: nextState, | |
| lifecycle_version: sessionRow.lifecycle_version + 1, | |
| ...buildActiveLifecycleUpdate(nextState), | |
| snapshot_url: null, | |
| snapshot_created_at: null, | |
| }); | |
| // Bind the sandbox to the session + mark it active via the shared helper | |
| // (also used by the headless `provisionGenerateSession`). It derives the | |
| // lifecycle fields from the state object's `expiresAt` — always populated by | |
| // the SDK, even on prebuilt-snapshot paths — rather than `sandbox.expiresAt`, | |
| // which is only set on some creation paths. | |
| await markSessionSandboxActive(sessionRow, sandbox.getState() as Json); | |
| if (sessionRow && sandbox.getState) { | |
| // Bind the sandbox to the session + mark it active via the shared helper | |
| // (also used by the headless `provisionGenerateSession`). It derives the | |
| // lifecycle fields from the state object's `expiresAt` — always populated by | |
| // the SDK, even on prebuilt-snapshot paths — rather than `sandbox.expiresAt`, | |
| // which is only set on some creation paths. | |
| const updatedSessionRow = await markSessionSandboxActive(sessionRow, sandbox.getState() as Json); | |
| if (!updatedSessionRow) { | |
| return NextResponse.json( | |
| { status: "error", error: "Failed to activate session sandbox" }, | |
| { status: 500, headers: getCorsHeaders() }, | |
| ); | |
| } | |
| sessionRow = updatedSessionRow; |
🤖 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 `@lib/sandbox/createSandboxHandler.ts` around lines 125 - 131, The session
activation path in createSandboxHandler ignores the possibility that
markSessionSandboxActive returns null, so the handler can still report success
even when the session was not activated. Update the logic around sessionRow,
sandbox.getState, and markSessionSandboxActive to check the helper’s return
value and treat a null result as a failure: stop the success response, surface
an appropriate error, and avoid returning 200 when the session lifecycle fields
could not be bound.
There was a problem hiding this comment.
3 issues found across 10 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/sandbox/createSandboxHandler.ts">
<violation number="1" location="lib/sandbox/createSandboxHandler.ts:131">
P2: `markSessionSandboxActive` can return `null` on DB error, but the return value is discarded here. The handler will still return 200, leaving the sandbox created while the session row remains unactivated. The headless path (`provisionGenerateSession`) correctly checks for `null` and throws — apply the same guard here to avoid an inconsistent state.</violation>
</file>
<file name="lib/chat/generate/provisionGenerateSession.ts">
<violation number="1" location="lib/chat/generate/provisionGenerateSession.ts:44">
P2: `workspaceAccountId` is not threaded through to `createSessionWithInitialChat`. For org-scoped runs, `ensurePersonalRepo` will resolve against `accountId` (the user) instead of the org/workspace account, diverging from the interactive `createSessionHandler` path which passes `workspaceAccountId: auth.orgId ?? auth.accountId`. Accept and forward a `workspaceAccountId` parameter so headless provisioning targets the correct workspace repository.</violation>
</file>
<file name="lib/sandbox/markSessionSandboxActive.ts">
<violation number="1" location="lib/sandbox/markSessionSandboxActive.ts:25">
P2: `lifecycle_version` is incremented client-side from a previously-read row and then written back unconditionally via `updateSession(..., { lifecycle_version: sessionRow.lifecycle_version + 1 })`. This read-modify-write pattern has no optimistic-lock guard, so concurrent activations of the same session can both compute the same next version and overwrite each other, producing a lost update.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| // lifecycle fields from the state object's `expiresAt` — always populated by | ||
| // the SDK, even on prebuilt-snapshot paths — rather than `sandbox.expiresAt`, | ||
| // which is only set on some creation paths. | ||
| await markSessionSandboxActive(sessionRow, sandbox.getState() as Json); |
There was a problem hiding this comment.
P2: markSessionSandboxActive can return null on DB error, but the return value is discarded here. The handler will still return 200, leaving the sandbox created while the session row remains unactivated. The headless path (provisionGenerateSession) correctly checks for null and throws — apply the same guard here to avoid an inconsistent state.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sandbox/createSandboxHandler.ts, line 131:
<comment>`markSessionSandboxActive` can return `null` on DB error, but the return value is discarded here. The handler will still return 200, leaving the sandbox created while the session row remains unactivated. The headless path (`provisionGenerateSession`) correctly checks for `null` and throws — apply the same guard here to avoid an inconsistent state.</comment>
<file context>
@@ -124,21 +123,12 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
+ // lifecycle fields from the state object's `expiresAt` — always populated by
+ // the SDK, even on prebuilt-snapshot paths — rather than `sandbox.expiresAt`,
+ // which is only set on some creation paths.
+ await markSessionSandboxActive(sessionRow, sandbox.getState() as Json);
}
</file context>
| await markSessionSandboxActive(sessionRow, sandbox.getState() as Json); | |
| const updatedSessionRow = await markSessionSandboxActive(sessionRow, sandbox.getState() as Json); | |
| if (!updatedSessionRow) { | |
| return NextResponse.json( | |
| { status: "error", error: "Failed to activate session sandbox" }, | |
| { status: 500, headers: getCorsHeaders() }, | |
| ); | |
| } |
| title: string; | ||
| artistId?: string; | ||
| }): Promise<ProvisionedGenerateSession> { | ||
| const created = await createSessionWithInitialChat({ |
There was a problem hiding this comment.
P2: workspaceAccountId is not threaded through to createSessionWithInitialChat. For org-scoped runs, ensurePersonalRepo will resolve against accountId (the user) instead of the org/workspace account, diverging from the interactive createSessionHandler path which passes workspaceAccountId: auth.orgId ?? auth.accountId. Accept and forward a workspaceAccountId parameter so headless provisioning targets the correct workspace repository.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/generate/provisionGenerateSession.ts, line 44:
<comment>`workspaceAccountId` is not threaded through to `createSessionWithInitialChat`. For org-scoped runs, `ensurePersonalRepo` will resolve against `accountId` (the user) instead of the org/workspace account, diverging from the interactive `createSessionHandler` path which passes `workspaceAccountId: auth.orgId ?? auth.accountId`. Accept and forward a `workspaceAccountId` parameter so headless provisioning targets the correct workspace repository.</comment>
<file context>
@@ -47,44 +41,39 @@ export async function provisionGenerateSession({
- id: generateUUID(),
- session_id: session.id,
- title: "Scheduled generation",
+ const created = await createSessionWithInitialChat({
+ accountId,
+ title,
</file context>
| ): Promise<Tables<"sessions"> | null> { | ||
| return updateSession(sessionRow.id, { | ||
| sandbox_state: sandboxState, | ||
| lifecycle_version: sessionRow.lifecycle_version + 1, |
There was a problem hiding this comment.
P2: lifecycle_version is incremented client-side from a previously-read row and then written back unconditionally via updateSession(..., { lifecycle_version: sessionRow.lifecycle_version + 1 }). This read-modify-write pattern has no optimistic-lock guard, so concurrent activations of the same session can both compute the same next version and overwrite each other, producing a lost update.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sandbox/markSessionSandboxActive.ts, line 25:
<comment>`lifecycle_version` is incremented client-side from a previously-read row and then written back unconditionally via `updateSession(..., { lifecycle_version: sessionRow.lifecycle_version + 1 })`. This read-modify-write pattern has no optimistic-lock guard, so concurrent activations of the same session can both compute the same next version and overwrite each other, producing a lost update.</comment>
<file context>
@@ -0,0 +1,30 @@
+): Promise<Tables<"sessions"> | null> {
+ return updateSession(sessionRow.id, {
+ sandbox_state: sandboxState,
+ lifecycle_version: sessionRow.lifecycle_version + 1,
+ ...buildActiveLifecycleUpdate(sandboxState),
+ snapshot_url: null,
</file context>
…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) <noreply@anthropic.com>
There was a problem hiding this comment.
0 issues found across 14 files (changes from recent commits).
Requires human review: Auto-approval blocked by 7 unresolved issues from previous reviews.
Re-trigger cubic
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#719) * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: prettier-format connectors file-upload tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* fix: repoint dead .com hosts to live .dev (recoupable/chat#1819 §A+§B) (#719)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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…
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* fix: repoint dead .com hosts to live .dev (recoupable/chat#1819 §A+§B) (#719)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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…
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* fix: repoint dead .com hosts to live .dev (recoupable/chat#1819 §A+§B) (#719)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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…
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* fix: repoint dead .com hosts to live .dev (recoupable/chat#1819 §A+§B) (#719)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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…
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* fix: repoint dead .com hosts to live .dev (recoupable/chat#1819 §A+§B) (#719)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* style: prettier-format connectors file-upload tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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…
PR 2 (api, the keystone) of the async chat-generation migration tracked in recoupable/chat#1813. Fulfills the contract in docs#249. Merge order: docs#249 → this.
What
POST /api/chat/generateis re-pointed from the synchronous legacyToolLoopAgentonto the same durablerunAgentWorkflowthat powers interactive/api/chat. It now:ensurePersonalRepo→insertSession→insertChat→connectSandbox→updateSession(active) →discoverSkills).recoup_sk_key (mintEphemeralAccountKey, re-added from the deferred half of Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) #700 — minting now has its only consumer), injected as the agent'srecoupAccessTokenso the long-lived service key never enters model-issued bash.RunAgentWorkflowInputviabuildRunAgentInput(Extract shared buildRunAgentInput() (chat#1813) #701) andstart()s the run.{ runId }with 202 immediately — generation, assistant-message persistence, the credit charge, and key revocation happen server-side inside the workflow.Ephemeral key lifecycle
agentContext.ephemeralKeyId;runAgentWorkflow'sfinallydeletes it on run end (deleteEphemeralKeyStep).expires_atTTL (enforced ingetApiKeyAccountId, shipped in Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) #700) is the defense-in-depth backstop.Tests (TDD, red→green)
validateGenerateRequest(5),handleChatGenerateorchestration incl. start-failure key revocation (4),mintEphemeralAccountKeyre-added (2),runAgentWorkflowephemeral-key deletion on/off (2). Fulllib/chat+lib/keys+app/lib/workflowssuites green (571);tscadds 0 new errors.Notes
setupChatRequest,getGeneralAgent,saveChatCompletion, MCP+Composio tool wiring) are now unused by this path; left in place to keep the diff focused — a follow-up can remove any that are fully orphaned.🤖 Generated with Claude Code
Summary by cubic
Re-pointed chat generation onto the durable
runAgentWorkflowand shipped async runs viaPOST /api/chat/runswith a status endpoint atGET /api/chat/runs/{runId}. Each run provisions its own session and active sandbox, mints a short-lived account key, and revokes it when the workflow ends.New Features
POST /api/chat/runs(replaces and removes/api/chat/generate): provisions session + chat, connects an active sandbox, discovers skills, starts the workflow, and returns 202 with{ runId, chatId, sessionId }andLocation: /api/chat/runs/{runId}.GET /api/chat/runs/{runId}: returns{ runId, status }normalized toqueued|running|completed|failed|cancelled; 404 if unknown.recoup_sk_viamintEphemeralAccountKey, injects asrecoupAccessToken, threadsephemeralKeyId; workflowfinallydeletes it (deleteEphemeralKeyStep; ~15m TTL as backstop).Refactors
createSessionWithInitialChatandmarkSessionSandboxActive; used by headless runs,POST /api/sessions, andPOST /api/sandbox(fixes rollback gap on chat insert failure).normalizeRunStatussplit into its own module.insertApiKeynow writesexpires_atfor ephemeral keys.lib/chat/generate→lib/chat/runsto match the/api/chat/runsroute.Written for commit 10a727c. Summary will update on new commits.
Summary by CodeRabbit
New Features
promptormessages, plus model selection.Bug Fixes
Refactor