feat: POST /api/catalogs (create + materialize from valuation snapshot)#677
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
More reviews will be available in 52 minutes and 7 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 refill rate. 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, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. 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 (1)
📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds a new ChangesPOST /api/catalogs endpoint
Sequence Diagram(s)sequenceDiagram
participant Client
participant CatalogsRoute as app/api/catalogs
participant createCatalogHandler
participant validateCreateCatalogBody
participant validateAuthContext
participant createSnapshotCatalog
participant Supabase
Client->>CatalogsRoute: POST /api/catalogs
CatalogsRoute->>createCatalogHandler: forward request
createCatalogHandler->>validateCreateCatalogBody: parse + validate body
validateCreateCatalogBody-->>createCatalogHandler: 400 or CreateCatalogBody
createCatalogHandler->>validateAuthContext: resolve accountId
validateAuthContext-->>createCatalogHandler: 401 or accountId
alt No snapshot in body
createCatalogHandler->>Supabase: insertCatalog(name)
createCatalogHandler->>Supabase: insertAccountCatalog(accountId, catalog)
createCatalogHandler-->>Client: 200 {catalog, songs_added: 0}
else Snapshot provided
createCatalogHandler->>Supabase: selectPlaycountSnapshots(snapshotId)
Supabase-->>createCatalogHandler: snapshot row or null
createCatalogHandler-->>Client: 404 (not found) / 403 (wrong account)
alt Snapshot already has catalog
createCatalogHandler->>Supabase: selectCatalogById(snapshot.catalog)
createCatalogHandler-->>Client: 200 existing catalog
else No catalog yet
createCatalogHandler->>createSnapshotCatalog: accountId + snapshot + name?
createSnapshotCatalog->>Supabase: insertCatalog + insertAccountCatalog
createSnapshotCatalog->>Supabase: selectSnapshotIsrcs(snapshotId)
createSnapshotCatalog->>Supabase: insert catalog_songs + updatePlaycountSnapshot
createSnapshotCatalog-->>createCatalogHandler: {catalog, songsAdded}
createCatalogHandler-->>Client: 200 {catalog, songs_added}
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
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 |
Preview verificationTested against the preview built from this PR's HEAD (
Validation runs before auth, so the 400 cases are confirmable without a key. No secret is echoed in any response (case 5 returns a generic Not exercised live (needs a real credential + owned snapshot)The authenticated paths — name-only create (200), materialize from Docs ↔ API reconciled: the live error envelope, status codes, and the |
There was a problem hiding this comment.
3 issues found across 10 files
Confidence score: 2/5
- The highest risk is in
lib/catalog/createCatalogHandler.ts: the current idempotency check is not concurrency-safe, so two simultaneous claims for the same snapshot can both pass and create duplicate catalogs, leading to data integrity and downstream reconciliation problems—add an atomic claim step (conditional update/transaction) before materialization. lib/catalog/materializeSnapshotCatalog.tscan leave partially written catalog state on failure because materialization is not atomic; merging as-is risks inconsistent records that also undermine later idempotent re-claims—wrap the write sequence in a transaction (or equivalent all-or-nothing guard) before merge.- In
lib/supabase/playcount_snapshots/updateSnapshotCatalog.ts, the update path can report success even when no snapshot row exists, which can mask failed operations and make callers believe a catalog was updated when it was not—check affected-row count and return an explicit not-found/error outcome before merging.
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/catalog/createCatalogHandler.ts">
<violation number="1" location="lib/catalog/createCatalogHandler.ts:66">
P1: Idempotency is not concurrency-safe; concurrent claims of the same snapshot can create duplicate catalogs. Use an atomic claim (conditional update/transaction) before materialization.</violation>
</file>
<file name="lib/catalog/materializeSnapshotCatalog.ts">
<violation number="1" location="lib/catalog/materializeSnapshotCatalog.ts:30">
P1: Materialization is not atomic; partial failures can persist inconsistent state and break idempotent re-claim behavior.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as Client
participant API as POST /api/catalogs
participant Auth as validateAuthContext
participant Validator as validateCreateCatalogBody
participant Handler as createCatalogHandler
participant DB as Supabase DB
Note over Client,DB: Catalog Creation Flow (name-only)
Client->>API: POST /api/catalogs { name: "My Catalog" }
API->>Handler: createCatalogHandler(request)
Handler->>Validator: validateCreateCatalogBody(body)
Validator-->>Handler: { name: "My Catalog" }
Handler->>Auth: validateAuthContext(request)
Auth->>Auth: Resolve from Privy JWT or x-api-key
Auth-->>Handler: { accountId }
Handler->>DB: insertCatalog("My Catalog")
DB-->>Handler: catalog row
Handler->>DB: insertAccountCatalog({ account, catalog })
DB-->>Handler: ok
Handler-->>API: 200 { status, catalog, songs_added: 0 }
API-->>Client: 200 Response
Note over Client,DB: Materialized from Snapshot Flow
Client->>API: POST /api/catalogs { from: { snapshot_id } }
API->>Handler: createCatalogHandler(request)
Handler->>Validator: validateCreateCatalogBody(body)
Validator-->>Handler: { from: { snapshot_id } }
Handler->>Auth: validateAuthContext(request)
Auth-->>Handler: { accountId }
Handler->>DB: selectPlaycountSnapshots({ id: snapshotId })
DB-->>Handler: snapshot row
alt Snapshot not found
Handler-->>API: 404 Snapshot not found
else Snapshot owned by different account
Handler-->>API: 403 Snapshot belongs to a different account
else Snapshot already has a catalog (idempotent reclaim)
Handler->>DB: selectCatalogById(snapshot.catalog)
DB-->>Handler: existing catalog
Handler-->>API: 200 { status, catalog, songs_added: 0 }
else Happy path: owned + unclaimed snapshot
Handler->>Handler: materializeSnapshotCatalog({ accountId, snapshot, name })
Handler->>DB: insertCatalog(name ?? "Valuation Catalog")
DB-->>Handler: new catalog row
Handler->>DB: insertAccountCatalog({ account, catalog })
DB-->>Handler: ok
opt snapshot has ISRCs
Handler->>DB: insertCatalogSongs(isrcs mapped to catalog)
DB-->>Handler: ok
end
Handler->>DB: updateSnapshotCatalog({ snapshotId, catalogId })
DB-->>Handler: ok
Handler-->>Handler: return { catalog, songsAdded }
Handler-->>API: 200 { status, catalog, songs_added }
end
API-->>Client: Response
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| } | ||
|
|
||
| // Idempotent re-claim: the run already produced a catalog. | ||
| if (snapshot.catalog) { |
There was a problem hiding this comment.
P1: Idempotency is not concurrency-safe; concurrent claims of the same snapshot can create duplicate catalogs. Use an atomic claim (conditional update/transaction) before materialization.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/createCatalogHandler.ts, line 66:
<comment>Idempotency is not concurrency-safe; concurrent claims of the same snapshot can create duplicate catalogs. Use an atomic claim (conditional update/transaction) before materialization.</comment>
<file context>
@@ -0,0 +1,83 @@
+ }
+
+ // Idempotent re-claim: the run already produced a catalog.
+ if (snapshot.catalog) {
+ const existing = await selectCatalogById(snapshot.catalog);
+ if (existing) {
</file context>
| }): Promise<{ catalog: Tables<"catalogs">; songsAdded: number }> { | ||
| const { accountId, snapshot, name } = params; | ||
|
|
||
| const catalog = await insertCatalog(name ?? DEFAULT_CATALOG_NAME); |
There was a problem hiding this comment.
P1: Materialization is not atomic; partial failures can persist inconsistent state and break idempotent re-claim behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/materializeSnapshotCatalog.ts, line 30:
<comment>Materialization is not atomic; partial failures can persist inconsistent state and break idempotent re-claim behavior.</comment>
<file context>
@@ -0,0 +1,41 @@
+}): Promise<{ catalog: Tables<"catalogs">; songsAdded: number }> {
+ const { accountId, snapshot, name } = params;
+
+ const catalog = await insertCatalog(name ?? DEFAULT_CATALOG_NAME);
+ await insertAccountCatalog({ account: accountId, catalog: catalog.id });
+
</file context>
* feat: claim-on-click + deep-link the Get the full report CTA
The valuation result CTA now materializes the just-measured run into an
account-owned catalog and deep-links to it in chat, instead of dropping
the warm lead on the empty app homepage.
- Capture the snapshot id from the measurement-jobs response (id ===
snapshot_id for source:'current') and thread it onto the Result.
- New claimCatalog() POSTs /api/catalogs { from: { snapshot_id } } under
the Privy bearer (account derived server-side; idempotent re-claim).
- New GetFullReportCta client component: on click, claim then redirect to
chat.recoupable.com/catalogs/{id}; any failure or missing snapshot/token
falls back to the plain app URL so the user is never blocked.
Claim-on-click (not per-run) avoids creating catalogs for bounces.
Implements recoupable/chat#1801 Phase 3 (marketing). Calls POST /api/catalogs
from recoupable/api#677; lands authenticated once Privy cookies are enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: extract useGetFullReport hook from GetFullReportCta (SRP)
Move the loading state + claim/redirect onClick out of the component into
a co-located useGetFullReport hook (mirrors useArtistSearch /
useCatalogValuation). GetFullReportCta is now presentational.
Addresses review feedback on PR #28.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: move useGetFullReport to hooks/ folder
Hooks belong in hooks/, not components/ (KISS). git mv preserves history;
update the import in GetFullReportCta.
Addresses review feedback on PR #28.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
||
| const DEFAULT_CATALOG_NAME = "Valuation Catalog"; | ||
|
|
||
| function success(catalog: Tables<"catalogs">, songsAdded: number): NextResponse { |
There was a problem hiding this comment.
SRP
- actual: success defined in createCatalogHandler
- required: new lib file for the success function
There was a problem hiding this comment.
DRY - we should already have a shared lib for this. reference existing endpoints.
There was a problem hiding this comment.
Done in 7de73dd — dropped the inline success() helper and now use the shared successResponse({ catalog, songs_added }) from lib/networking/successResponse.ts (the existing helper, same one the research handlers use). SRP + DRY both addressed.
There was a problem hiding this comment.
KISS
- actual: updateSnapshotCatalog
- required: updatePlaycountSnapshot
There was a problem hiding this comment.
Done in 7de73dd — turned out there's already a generic updatePlaycountSnapshot(id, fields) in lib/supabase/playcount_snapshots/, so I deleted my redundant updateSnapshotCatalog and reuse that helper: updatePlaycountSnapshot(snapshot.id, { catalog: catalog.id }). DRY win.
- 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.
There was a problem hiding this comment.
2 issues found across 6 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/catalog/validateCreateCatalogBody.ts">
<violation number="1" location="lib/catalog/validateCreateCatalogBody.ts:8">
P1: Request schema no longer matches the POST /api/catalogs contract; documented `from.snapshot_id` payloads now 400. Restore `from: { snapshot_id }` validation (or accept both shapes) to avoid breaking existing clients.</violation>
</file>
<file name="lib/catalog/createCatalogHandler.ts">
<violation number="1" location="lib/catalog/createCatalogHandler.ts:42">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**
PR description claims body uses `from.snapshot_id` and function `materializeSnapshotCatalog`, but code uses flat `snapshot` property and `createSnapshotCatalog` — PR description does not match implemented code</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| export const createCatalogBodySchema = z | ||
| .object({ | ||
| name: z.string().min(1, "name must not be empty").optional(), | ||
| snapshot: z.string().uuid("snapshot must be a valid UUID").optional(), |
There was a problem hiding this comment.
P1: Request schema no longer matches the POST /api/catalogs contract; documented from.snapshot_id payloads now 400. Restore from: { snapshot_id } validation (or accept both shapes) to avoid breaking existing clients.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/validateCreateCatalogBody.ts, line 8:
<comment>Request schema no longer matches the POST /api/catalogs contract; documented `from.snapshot_id` payloads now 400. Restore `from: { snapshot_id }` validation (or accept both shapes) to avoid breaking existing clients.</comment>
<file context>
@@ -2,27 +2,24 @@ import { NextResponse } from "next/server";
.object({
name: z.string().min(1, "name must not be empty").optional(),
- from: materializationSourceSchema.optional(),
+ snapshot: z.string().uuid("snapshot must be a valid UUID").optional(),
})
- .refine(data => data.name !== undefined || data.from !== undefined, {
</file context>
| } | ||
| const { accountId } = authResult; | ||
|
|
||
| if (!validated.snapshot) { |
There was a problem hiding this comment.
P2: Custom agent: Flag AI Slop and Fabricated Changes
PR description claims body uses from.snapshot_id and function materializeSnapshotCatalog, but code uses flat snapshot property and createSnapshotCatalog — PR description does not match implemented code
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/createCatalogHandler.ts, line 42:
<comment>PR description claims body uses `from.snapshot_id` and function `materializeSnapshotCatalog`, but code uses flat `snapshot` property and `createSnapshotCatalog` — PR description does not match implemented code</comment>
<file context>
@@ -47,14 +39,13 @@ export async function createCatalogHandler(request: NextRequest): Promise<NextRe
const { accountId } = authResult;
- if (!validated.from) {
+ if (!validated.snapshot) {
const catalog = await insertCatalog(validated.name ?? DEFAULT_CATALOG_NAME);
await insertAccountCatalog({ account: accountId, catalog: catalog.id });
</file context>
Preview verification — re-anchored
|
| # | Case | Request | Expected | Actual | ✓ |
|---|---|---|---|---|---|
| 1 | CORS preflight | OPTIONS /api/catalogs |
200 | 200 | ✅ |
| 2 | Neither field | POST {} |
400 | 400 "Provide at least one of name or snapshot" |
✅ |
| 3 | Invalid snapshot |
POST {"snapshot":"not-a-uuid"} |
400 | 400 "snapshot must be a valid UUID", missing_fields:["snapshot"] |
✅ |
| 4 | Old from shape rejected |
POST {"from":{"snapshot_id":"<uuid>"}} |
400 | 400 "Provide at least one of name or snapshot" |
✅ |
| 5 | Valid snapshot, no creds |
POST {"snapshot":"<uuid>"} |
401 | 401 "Exactly one of x-api-key or Authorization…" |
✅ |
| 6 | name-only, no creds | POST {"name":"Test Catalog"} |
401 | 401 (same) | ✅ |
| 7 | Bad x-api-key |
POST + bogus key |
401 | 401 "Unauthorized" (no key echoed) |
✅ |
Key result: case 3 shows the field is now a flat root snapshot (missing_fields:["snapshot"], not ["from","snapshot_id"]), and case 4 confirms the old nested from shape is no longer accepted — both match the merged docs#243 contract. Validation still runs before auth, so the 400s are confirmable without a key; no secret is echoed (case 7).
Not exercised live (needs a real credential + owned snapshot)
The authenticated paths — name-only create (200), materialize from snapshot (200 + songs_added), 403 (snapshot owned by another account), 404 (snapshot missing), and the idempotent re-claim — are covered by the 8 createCatalogHandler unit tests (24 in lib/catalog, all green) but were not run against the preview (no API key in my env; materialize needs a real owned snapshot). Happy to run them end-to-end if you drop a key in.
Also verified the review-fix refactors didn't regress the contract: shared successResponse envelope ({status, catalog, songs_added}) is unchanged in the responses above.
Preview verification — authenticated success path ✅Followed up on the earlier (validation/auth-rejection only) run by testing the authenticated paths against the preview (
Case B is the real success path I was missing before — it exercises the full chain end-to-end: validate → Still not exercised live (needs a snapshot owned by the test account)The materialize-with-songs 200 ( Note: case B created a real catalog row ( |
…srcs 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.
There was a problem hiding this comment.
KISS
- actual: selectSnapshotIsrcs
- required: selectSongMeasurements
There was a problem hiding this comment.
Done in b7e49ba9 — deleted selectSnapshotIsrcs and instead added an optional snapshot filter to the existing selectSongMeasurements. createSnapshotCatalog now calls selectSongMeasurements({ snapshot: snapshot.id }) and dedupes the ISRCs. 36 tests green.
Full materialize path — tested live (minted a real snapshot)Minted a real album-scoped snapshot and exercised the materialize + idempotency paths end-to-end. This surfaced — and I fixed — a real bug. 🐞 Bug found & fixed (commit
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
lib/catalog/createCatalogHandler.ts (2)
12-12: ⚡ Quick winUse a single source of truth for the default catalog name.
DEFAULT_CATALOG_NAMEis duplicated here and inlib/catalog/createSnapshotCatalog.ts. If one value changes and the other does not, direct creation and snapshot materialization will diverge unexpectedly.As per coding guidelines, "Use constants for repeated values."
Also applies to: 43-44, 64-68
🤖 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/catalog/createCatalogHandler.ts` at line 12, The constant DEFAULT_CATALOG_NAME is duplicated across createCatalogHandler.ts and createSnapshotCatalog.ts, which violates the single source of truth principle. Extract DEFAULT_CATALOG_NAME to a shared constants file (such as lib/catalog/constants.ts or lib/constants.ts), then import and use this constant in both createCatalogHandler.ts and createSnapshotCatalog.ts, removing the local duplicate definitions to ensure consistency across direct creation and snapshot materialization.Source: Coding guidelines
27-74: ⚡ Quick winSplit
createCatalogHandlerinto smaller branch-specific helpers.This function currently handles multiple responsibilities in one >20-line unit (parse/validate/auth/direct-create/snapshot flow/response mapping). Extracting the direct-create and snapshot-create paths will make changes safer and easier to reason about.
As per coding guidelines, "Flag functions longer than 20 lines" and "Keep functions small and focused."
🤖 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/catalog/createCatalogHandler.ts` around lines 27 - 74, The createCatalogHandler function exceeds 20 lines and handles multiple responsibilities including direct catalog creation and snapshot-based creation. Extract the direct-create path (the block starting with "if (!validated.snapshot)" that calls insertCatalog and insertAccountCatalog) into a separate helper function like createDirectCatalog. Extract the snapshot-creation path (the block that validates the snapshot, handles the idempotent re-claim case, and calls createSnapshotCatalog) into a separate helper function like createSnapshotBasedCatalog. Refactor the main createCatalogHandler to orchestrate these helpers after validating the body and auth context, keeping the main function focused on request/response handling only.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 `@lib/catalog/createSnapshotCatalog.ts`:
- Around line 33-41: The write sequence in createSnapshotCatalog is not atomic,
meaning if any operation fails after some succeed, you'll have partial data
persisted (orphaned catalog records) and inconsistent state. Wrap all four
database operations (insertCatalog, insertAccountCatalog, insertCatalogSongs,
and updatePlaycountSnapshot) in a single database transaction so that either all
operations complete successfully or all are rolled back if any step fails,
preventing partial catalog creation and duplicate catalog issues on retry.
- Around line 36-43: The selectSnapshotIsrcs function returns an empty array
both when no ISRCs are found and when a query error occurs, making it impossible
to distinguish between a legitimate empty result and a failure. This causes the
code to proceed with updatePlaycountSnapshot and return success even when the
ISRC lookup failed. Modify selectSnapshotIsrcs to throw an error on query
failure instead of silently returning an empty array, or add error handling in
the calling code to catch and propagate query failures before
updatePlaycountSnapshot is called. This ensures that materialization fails when
data retrieval fails rather than treating the failure as an empty successful
catalog.
In `@lib/supabase/song_measurements/selectSnapshotIsrcs.ts`:
- Around line 18-21: The error handling in the selectSnapshotIsrcs function at
the if (error) block is masking failures by returning an empty array instead of
propagating the error. Replace the return statement with a throw statement to
propagate the error up to the handler, allowing it to properly fail the request
rather than continuing with partial or missing data, which would result in
incorrect catalog materialization and false reporting.
---
Nitpick comments:
In `@lib/catalog/createCatalogHandler.ts`:
- Line 12: The constant DEFAULT_CATALOG_NAME is duplicated across
createCatalogHandler.ts and createSnapshotCatalog.ts, which violates the single
source of truth principle. Extract DEFAULT_CATALOG_NAME to a shared constants
file (such as lib/catalog/constants.ts or lib/constants.ts), then import and use
this constant in both createCatalogHandler.ts and createSnapshotCatalog.ts,
removing the local duplicate definitions to ensure consistency across direct
creation and snapshot materialization.
- Around line 27-74: The createCatalogHandler function exceeds 20 lines and
handles multiple responsibilities including direct catalog creation and
snapshot-based creation. Extract the direct-create path (the block starting with
"if (!validated.snapshot)" that calls insertCatalog and insertAccountCatalog)
into a separate helper function like createDirectCatalog. Extract the
snapshot-creation path (the block that validates the snapshot, handles the
idempotent re-claim case, and calls createSnapshotCatalog) into a separate
helper function like createSnapshotBasedCatalog. Refactor the main
createCatalogHandler to orchestrate these helpers after validating the body and
auth context, keeping the main function focused on request/response handling
only.
🪄 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: 5795046d-a4ed-4344-ace3-8323f18d36af
⛔ Files ignored due to path filters (3)
lib/catalog/__tests__/createCatalogHandler.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/catalog/__tests__/createSnapshotCatalog.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/catalog/__tests__/validateCreateCatalogBody.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (8)
app/api/catalogs/route.tslib/catalog/createCatalogHandler.tslib/catalog/createSnapshotCatalog.tslib/catalog/validateCreateCatalogBody.tslib/supabase/account_catalogs/insertAccountCatalog.tslib/supabase/catalogs/insertCatalog.tslib/supabase/catalogs/selectCatalogById.tslib/supabase/song_measurements/selectSnapshotIsrcs.ts
| const catalog = await insertCatalog(name ?? DEFAULT_CATALOG_NAME); | ||
| await insertAccountCatalog({ account: accountId, catalog: catalog.id }); | ||
|
|
||
| const isrcs = await selectSnapshotIsrcs(snapshot.id); | ||
| if (isrcs.length > 0) { | ||
| await insertCatalogSongs(isrcs.map(isrc => ({ catalog: catalog.id, song: isrc }))); | ||
| } | ||
|
|
||
| await updatePlaycountSnapshot(snapshot.id, { catalog: catalog.id }); |
There was a problem hiding this comment.
Make the write sequence atomic to prevent partial catalog creation.
Catalog insert, account link insert, song inserts, and snapshot update are independent writes. If a later step fails, earlier rows persist, leaving inconsistent state and allowing duplicate catalogs on retry because the snapshot may remain unclaimed.
🤖 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/catalog/createSnapshotCatalog.ts` around lines 33 - 41, The write
sequence in createSnapshotCatalog is not atomic, meaning if any operation fails
after some succeed, you'll have partial data persisted (orphaned catalog
records) and inconsistent state. Wrap all four database operations
(insertCatalog, insertAccountCatalog, insertCatalogSongs, and
updatePlaycountSnapshot) in a single database transaction so that either all
operations complete successfully or all are rolled back if any step fails,
preventing partial catalog creation and duplicate catalog issues on retry.
| if (error) { | ||
| console.error("Error fetching snapshot ISRCs:", error); | ||
| return []; | ||
| } |
There was a problem hiding this comment.
Do not swallow snapshot-measurement query failures.
At Line 18, returning [] on query error turns a backend failure into a false successful materialization path, which can create an incomplete catalog and report songs_added: 0 incorrectly. Throw here so the handler can fail the request instead of persisting partial results.
Suggested fix
if (error) {
- console.error("Error fetching snapshot ISRCs:", error);
- return [];
+ throw new Error(`Failed to fetch snapshot ISRCs: ${error.message}`);
}As per coding guidelines, "Handle errors gracefully" means surfacing operational failures instead of silently degrading write-path outcomes.
📝 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 (error) { | |
| console.error("Error fetching snapshot ISRCs:", error); | |
| return []; | |
| } | |
| if (error) { | |
| throw new Error(`Failed to fetch snapshot ISRCs: ${error.message}`); | |
| } |
🤖 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/song_measurements/selectSnapshotIsrcs.ts` around lines 18 - 21,
The error handling in the selectSnapshotIsrcs function at the if (error) block
is masking failures by returning an empty array instead of propagating the
error. Replace the return statement with a throw statement to propagate the
error up to the handler, allowing it to properly fail the request rather than
continuing with partial or missing data, which would result in incorrect catalog
materialization and false reporting.
Source: Coding guidelines
… 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.
Re-test on latest preview (
|
| # | Case | Result |
|---|---|---|
| 1 | OPTIONS |
200 ✅ |
| 2 | POST {} |
400 "Provide at least one of name or snapshot" ✅ |
| 3 | POST {"snapshot":"nope"} |
400 "snapshot must be a valid UUID" ✅ |
| 4 | POST {"from":{"snapshot_id":…}} (old shape) |
400 rejected ✅ |
| 5 | name-only, no creds | 401 ✅ |
| 6 | name-only + Bearer | 200 {catalog:{id:"94074fa7-…"}, songs_added:0} ✅ |
| 7 | {"snapshot":"<random uuid>"} + Bearer |
404 "Snapshot not found" ✅ |
| 8 | Idempotent re-claim of already-claimed snapshot | 200, same catalog 929814d1-…, songs_added:0 ✅ |
| 9 | Materialize (fresh) — mint snapshot 45efd92c-… → capture → claim |
200 {catalog:{id:"2f06a67d-…","Materialize Re-test b7e49ba"}, songs_added:23} ✅ |
Case 9 is the key reconfirmation: minted a new album-scoped snapshot, the preview captured 23 tracks into song_measurements, and the claim sourced them through the refactored selectSongMeasurements({ snapshot }) → 23 catalog songs. Create / materialize / idempotent all correct on the latest commit.
Unchanged (separate, still open): GET /api/catalogs/songs still returns total_count: 0 for materialized catalogs — the song_artists!inner join + valuation-captured tracks lacking song_artists. Needs the follow-up discussed earlier; not in this endpoint's scope.
(Left a few more test catalogs/snapshots under the preview account — no delete-catalog endpoint to clean them up.)
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/catalog/createSnapshotCatalog.ts">
<violation number="1" location="lib/catalog/createSnapshotCatalog.ts:36">
P1: Materialization can silently succeed with zero songs when measurement query fails. This can permanently claim a snapshot with incomplete catalog data.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| const catalog = await insertCatalog(name ?? DEFAULT_CATALOG_NAME); | ||
| await insertAccountCatalog({ account: accountId, catalog: catalog.id }); | ||
|
|
||
| const measurements = await selectSongMeasurements({ snapshot: snapshot.id }); |
There was a problem hiding this comment.
P1: Materialization can silently succeed with zero songs when measurement query fails. This can permanently claim a snapshot with incomplete catalog data.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/createSnapshotCatalog.ts, line 36:
<comment>Materialization can silently succeed with zero songs when measurement query fails. This can permanently claim a snapshot with incomplete catalog data.</comment>
<file context>
@@ -30,7 +33,8 @@ export async function createSnapshotCatalog(params: {
await insertAccountCatalog({ account: accountId, catalog: catalog.id });
- const isrcs = snapshot.isrcs ?? [];
+ const measurements = await selectSongMeasurements({ snapshot: snapshot.id });
+ const isrcs = [...new Set(measurements.map(m => m.song))];
if (isrcs.length > 0) {
</file context>
* 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> --------- 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 --------- 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> --------- 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> --------- 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> --------- 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> --------- 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> --------- 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> --------- 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> --------- 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> --------- 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> --------- 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> --------- 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> --------- 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…
* 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: r…
…d} (chat#1840) (#752) * 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 tak…
What
Implements
POST /api/catalogsper the contract in recoupable/docs#243. Creates a catalog owned by the authenticated account, and optionally materializes it from a completed valuation snapshot — closing the "catalog empty after sign-in" gap from recoupable/chat#1801 (Phase 2).validateAuthContextresolves the owner from the Privy bearer JWT orx-api-key. Noaccount_idis read from the request → no IDOR.from.snapshot_idmaterialization. Verifies the snapshot is owned by the caller (403 otherwise), then creates thecatalogsrow, linksaccount_catalogs, adds the snapshot's measured ISRCs ascatalog_songs, and records the catalog on the snapshot.catalogFK is the dedupe key — re-claiming returns the catalog already created for that run (songs_added: 0), no duplicates. No DB migration needed (the column already exists).Contract
POST /api/catalogs→{ name?, from?: { snapshot_id } }(≥1 required)200→{ status, catalog, songs_added }(also the idempotent re-claim response)400neither name nor from / bad snapshot_id ·401unauth ·403snapshot owned by another account ·404snapshot not foundTDD
Red → green, one unit at a time:
validateCreateCatalogBody— 6 tests (name-only, from-only, both, empty→400, bad uuid→400, null→400)createCatalogHandler— 8 tests (validator/auth short-circuits, plain create, materialize happy path, 404, 403, idempotent re-claim, generic 500)SRP: one exported fn/file. New supabase wrappers (
insertCatalog,selectCatalogById,insertAccountCatalog,updateSnapshotCatalog) mirror the existinginsertCatalogSongsstyle;materializeSnapshotCatalogextracted from the handler.pnpm exec vitest run lib/catalog→ 24 passed.eslintclean on all new files.tsc --noEmit: my files type-clean (pre-existing unrelated__tests__type-drift inlib/tasks/lib/trigger/etc. is unchanged by this PR;next buildexcludes test files).Merge order
docs#243 (contract) first, then this. Verification against the preview to follow in a comment.
🤖 Generated with Claude Code
Summary by cubic
Adds
POST /api/catalogsto create a catalog for the authenticated account, with optional materialization from a valuation snapshot via thesnapshotfield. Returns the catalog andsongs_added; re-claiming a snapshot is idempotent.New Features
x-api-key); noaccount_idin the body.catalogs, linkaccount_catalogs, load measured ISRCs fromsong_measurementsintocatalog_songs, and record the catalog on the snapshot.nameisn’t provided; CORSOPTIONSadded.Refactors
{ name?, snapshot? }to match the merged contract.successResponse; renamed tocreateSnapshotCatalog; replaced a redundant update helper withupdatePlaycountSnapshot.selectSongMeasurementswith a newsnapshotfilter (dropped the dedicated helper) and deduped ISRCs before insert.Written for commit b7e49ba. Summary will update on new commits.
Summary by CodeRabbit