Skip to content

feat: POST /api/catalogs (create + materialize from valuation snapshot)#677

Merged
sweetmantech merged 4 commits into
testfrom
feat/create-catalog-endpoint
Jun 18, 2026
Merged

feat: POST /api/catalogs (create + materialize from valuation snapshot)#677
sweetmantech merged 4 commits into
testfrom
feat/create-catalog-endpoint

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What

Implements POST /api/catalogs per 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).

  • Account from credentials, never the body. validateAuthContext resolves the owner from the Privy bearer JWT or x-api-key. No account_id is read from the request → no IDOR.
  • from.snapshot_id materialization. Verifies the snapshot is owned by the caller (403 otherwise), then creates the catalogs row, links account_catalogs, adds the snapshot's measured ISRCs as catalog_songs, and records the catalog on the snapshot.
  • Idempotent. The snapshot's existing catalog FK 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)
  • 400 neither name nor from / bad snapshot_id · 401 unauth · 403 snapshot owned by another account · 404 snapshot not found

TDD

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 existing insertCatalogSongs style; materializeSnapshotCatalog extracted from the handler.

pnpm exec vitest run lib/catalog24 passed. eslint clean on all new files. tsc --noEmit: my files type-clean (pre-existing unrelated __tests__ type-drift in lib/tasks/lib/trigger/etc. is unchanged by this PR; next build excludes 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/catalogs to create a catalog for the authenticated account, with optional materialization from a valuation snapshot via the snapshot field. Returns the catalog and songs_added; re-claiming a snapshot is idempotent.

  • New Features

    • Auth via credentials (Privy bearer JWT or x-api-key); no account_id in the body.
    • Materialize from an owned snapshot: create catalogs, link account_catalogs, load measured ISRCs from song_measurements into catalog_songs, and record the catalog on the snapshot.
    • Defaults to "Valuation Catalog" when name isn’t provided; CORS OPTIONS added.
  • Refactors

    • Flattened request body to { name?, snapshot? } to match the merged contract.
    • Reused shared successResponse; renamed to createSnapshotCatalog; replaced a redundant update helper with updatePlaycountSnapshot.
    • Reused selectSongMeasurements with a new snapshot filter (dropped the dedicated helper) and deduped ISRCs before insert.

Written for commit b7e49ba. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Users can now create new catalogs with optional custom names through a dedicated API endpoint.
    • Support for creating catalogs from existing playcount snapshots, with automatic linking of measured songs from those snapshots to the new catalog.
    • Implemented comprehensive request validation and error handling to ensure proper data submission for catalog creation operations.

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>
@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jun 18, 2026 7:54pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sweetmantech, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2ba1cc55-39ad-41ad-a63a-e748a11d3b6c

📥 Commits

Reviewing files that changed from the base of the PR and between 66c61b0 and b7e49ba.

⛔ Files ignored due to path filters (1)
  • lib/catalog/__tests__/createSnapshotCatalog.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (2)
  • lib/catalog/createSnapshotCatalog.ts
  • lib/supabase/song_measurements/selectSongMeasurements.ts
📝 Walkthrough

Walkthrough

Adds a new POST /api/catalogs Next.js API route. The route supports two catalog creation paths: a direct creation (by name) and snapshot-driven materialization that reads ISRCs from song_measurements, inserts catalog_songs, and updates the snapshot record. Idempotent re-claim is handled when a snapshot already references an existing catalog. A Zod validator enforces that at least one of name or snapshot is present in the request body.

Changes

POST /api/catalogs endpoint

Layer / File(s) Summary
Request body validation schema
lib/catalog/validateCreateCatalogBody.ts
Defines createCatalogBodySchema (Zod, optional name and snapshot, at least one required), exports CreateCatalogBody type, and implements validateCreateCatalogBody returning a CORS-enabled 400 on failure or the typed body on success.
Supabase data-access helpers
lib/supabase/catalogs/insertCatalog.ts, lib/supabase/account_catalogs/insertAccountCatalog.ts, lib/supabase/catalogs/selectCatalogById.ts, lib/supabase/song_measurements/selectSnapshotIsrcs.ts
Four new helpers: insert a catalog row, link account↔catalog, look up a catalog by id, and fetch distinct ISRCs from song_measurements for a given snapshot.
Snapshot catalog materialization
lib/catalog/createSnapshotCatalog.ts
createSnapshotCatalog creates a catalog, links it to the account, conditionally inserts catalog_songs rows from the snapshot's ISRCs, updates the snapshot's catalog reference, and returns { catalog, songsAdded }.
Request handler logic
lib/catalog/createCatalogHandler.ts
Parses JSON, validates body and auth, branches on snapshot presence: direct creation returns songs_added: 0; snapshot path enforces 404/403 ownership, performs idempotent re-claim if snapshot already has a catalog, otherwise calls createSnapshotCatalog. Errors are caught and returned as 500.
Next.js route wiring
app/api/catalogs/route.ts
Exports OPTIONS (CORS preflight, 200) and POST (delegates to createCatalogHandler) handlers.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Poem

A catalog blooms from a snapshot's seed,
With ISRCs fetched at idempotent speed,
Zod guards the gate, auth checks the key,
403, 404, or a bright 200 — free!
Clean helpers compose, each single in role,
SOLID and tidy, the endpoint is whole. 🎵

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning PR has good SRP (file-function naming, appropriate lengths, consistent patterns) but fails Clean Code error handling: selectSnapshotIsrcs returns [] on failure (masking errors), and multi-step writ... Fix selectSnapshotIsrcs to throw on query errors instead of returning []. Wrap createSnapshotCatalog writes in a transaction or implement rollback logic to prevent partial state on failure.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/create-catalog-endpoint

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification

Tested against the preview built from this PR's HEAD (c2c0b63) → https://api-cflbqby4n-recoup.vercel.app.

# Case Request Documented Actual
1 CORS preflight OPTIONS /api/catalogs 200 200
2 Neither name nor from POST {} 400 400 "Provide at least one of name or from"
3 Invalid snapshot_id POST {"from":{"snapshot_id":"not-a-uuid"}} 400 400 "snapshot_id must be a valid UUID"
4 No credentials POST {"name":"Test Catalog"} 401 401 "Exactly one of x-api-key or Authorization must be provided"
5 Bad credentials POST + bogus x-api-key 401 401 "Unauthorized"

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 "Unauthorized", not the supplied key).

Not exercised live (needs a real credential + owned snapshot)

The authenticated paths — name-only create (200), materialize from from.snapshot_id (200 + songs_added), the 403 (snapshot owned by another account), 404 (snapshot missing), and the idempotent re-claim — are covered by the 8 createCatalogHandler unit tests but were not run against the preview (no API key in my environment, and materialize needs a real snapshot owned by that account). @sweetmantech can validate end-to-end with a real key, or I can if you drop one in.

Docs ↔ API reconciled: the live error envelope, status codes, and the {status, catalog, songs_added} success shape match recoupable/docs#243. One refinement I'll push to the docs PR: the catalog-name fallback when from is supplied without name is the constant "Valuation Catalog" (the snapshot has no artist-name column); marketing passes the display name explicitly.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.ts can 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
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

}

// Idempotent re-claim: the run already produced a catalog.
if (snapshot.catalog) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

Comment thread lib/supabase/playcount_snapshots/updateSnapshotCatalog.ts Outdated
sweetmantech added a commit to recoupable/marketing that referenced this pull request Jun 18, 2026
* 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>
Comment thread lib/catalog/createCatalogHandler.ts Outdated

const DEFAULT_CATALOG_NAME = "Valuation Catalog";

function success(catalog: Tables<"catalogs">, songsAdded: number): NextResponse {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP

  • actual: success defined in createCatalogHandler
  • required: new lib file for the success function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DRY - we should already have a shared lib for this. reference existing endpoints.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/catalog/createSnapshotCatalog.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

KISS

  • actual: updateSnapshotCatalog
  • required: updatePlaycountSnapshot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — re-anchored snapshot contract

Re-tested against the preview built from HEAD (7de73dd) → https://api-7mrswkzi4-recoup.vercel.app. CI on this commit: lint / test / format all green.

# 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.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — authenticated success path ✅

Followed up on the earlier (validation/auth-rejection only) run by testing the authenticated paths against the preview (https://api-7mrswkzi4-recoup.vercel.app, HEAD 7de73dd), using a Privy Bearer minted from the preview's Privy app.

# Case Request Result
B Create (success) POST {"name":"…"} + Bearer 200 {"status":"success","catalog":{"id":"a281df58-…","name":"Preview Verify Catalog 7de73dd","created_at":…,"updated_at":…},"songs_added":0}
C Catalog is real GET /api/catalogs/songs?catalog_id=a281df58-… 200 {"status":"success","songs":[],"pagination":{"total_count":0,…}}
D Snapshot not found POST {"snapshot":"00000000-…"} + Bearer 404 {"status":"error","error":"Snapshot not found"}
E Invalid snapshot (authed) POST {"snapshot":"nope"} + Bearer 400 "snapshot must be a valid UUID", missing_fields:["snapshot"]

Case B is the real success path I was missing before — it exercises the full chain end-to-end: validate → validateAuthContext (Bearer) → insertCataloginsertAccountCatalog → the shared successResponse envelope (the refactor from the review). The response shape {status, catalog, songs_added} is exactly the merged contract. Case D confirms the authenticated snapshot-lookup branch (selectPlaycountSnapshots → empty → 404).

Still not exercised live (needs a snapshot owned by the test account)

The materialize-with-songs 200 (songs_added > 0 via createSnapshotCatalog), the 403 (snapshot owned by another account), and the idempotent re-claim need a real playcount_snapshots row owned by the authenticated account — the preview test account has none (hence the 404 in D). These three are covered by the createCatalogHandler unit tests (24 green in lib/catalog). I can run them live too if you want me to kick off a measurement-job to mint a snapshot for that account (it spends a research/scrape run).

Note: case B created a real catalog row (a281df58-…) under the preview test account — there's no delete-catalog endpoint, so it's left as a harmless test artifact.

…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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

KISS

  • actual: selectSnapshotIsrcs
  • required: selectSongMeasurements

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

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 66c61b0)

createSnapshotCatalog read snapshot.isrcs, but a valuation snapshot is album-scoped (scope:{album_ids}), so that column is null — materialize would link an empty catalog. The measured ISRCs live in song_measurements (snapshot lineage). Fix: new selectSnapshotIsrcs(snapshotId) (distinct song_measurements.song for the snapshot); createSnapshotCatalog now sources from it. TDD: new createSnapshotCatalog.test.ts (3 tests) red→green; lib/catalog 27 pass.

Live test (preview api-grt4b9ggq, commit 66c61b0)

  1. POST /api/research/measurement-jobs {source:"current", scope:{album_ids:["3RQQmkQEvNCY4prGKE6oc5"]}}202, snapshot_id=0bb34668-…. Capture ran on the preview (Apify) → fresh song_measurements for 23 tracks (Callaita, Tití Me Preguntó, …).
  2. Materialize: POST /api/catalogs {"snapshot":"0bb34668-…"}200 {catalog:{id:"929814d1-…","Valuation Catalog"}, songs_added:23} ✅ — 23 measured ISRCs upserted as catalog_songs (no error → FK to songs.isrc satisfied).
  3. Idempotent re-claim: same POST again → 200, same catalog 929814d1-…, songs_added:0 ✅ (snapshot's catalog FK short-circuits; no duplicate).

So POST /api/catalogs now does its job correctly across create / materialize / idempotent.

⚠️ Separate gap found (NOT this PR's scope) — blocks the issue's GET Done-when

GET /api/catalogs/songs?catalog_id=929814d1-… returns total_count: 0 despite the 23 rows. Cause: selectCatalogSongsWithArtists uses song_artists!inneraccounts!inner, and the valuation-capture pipeline writes songs + song_measurements but no song_artists for these tracks (confirmed: GET /api/songs?isrc=QM6N21919851 also returns 0). So the tracks are real and linked to the catalog, but invisible to both artist-inner-joined read endpoints.

This is outside POST /api/catalogs (which correctly created the catalog_songs). The issue's Done-when "GET /api/catalogs/songs returns the measured tracks" needs a follow-up: either the capture writes song_artists, or the GET endpoints LEFT-join artists so artist-less tracks still surface. Recommend tracking on chat#1801. Happy to take whichever direction you prefer.

Note: this left two test catalogs (a281df58-…, 929814d1-…) + a snapshot under the preview Privy-app account; no delete-catalog endpoint exists to clean them up.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
lib/catalog/createCatalogHandler.ts (2)

12-12: ⚡ Quick win

Use a single source of truth for the default catalog name.

DEFAULT_CATALOG_NAME is duplicated here and in lib/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 win

Split createCatalogHandler into 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5472795 and 66c61b0.

⛔ Files ignored due to path filters (3)
  • lib/catalog/__tests__/createCatalogHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/catalog/__tests__/createSnapshotCatalog.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/catalog/__tests__/validateCreateCatalogBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (8)
  • app/api/catalogs/route.ts
  • lib/catalog/createCatalogHandler.ts
  • lib/catalog/createSnapshotCatalog.ts
  • lib/catalog/validateCreateCatalogBody.ts
  • lib/supabase/account_catalogs/insertAccountCatalog.ts
  • lib/supabase/catalogs/insertCatalog.ts
  • lib/supabase/catalogs/selectCatalogById.ts
  • lib/supabase/song_measurements/selectSnapshotIsrcs.ts

Comment on lines +33 to +41
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.

Comment thread lib/catalog/createSnapshotCatalog.ts Outdated
Comment on lines +18 to +21
if (error) {
console.error("Error fetching snapshot ISRCs:", error);
return [];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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.
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Re-test on latest preview (b7e49baapi-mh958bc9m)

Re-ran the full matrix after the selectSongMeasurements refactor — all green, including a fresh materialize.

# 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.)

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

@sweetmantech sweetmantech merged commit f24d4c7 into test Jun 18, 2026
6 checks passed
sweetmantech added a commit that referenced this pull request Jun 18, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 18, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 18, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 18, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 18, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 19, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 20, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 23, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 23, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 23, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 24, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 24, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 24, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 24, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 25, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 25, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 25, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 25, 2026
* 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>
sweetmantech added a commit that referenced this pull request Jun 29, 2026
#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>
sweetmantech added a commit that referenced this pull request Jun 29, 2026
* 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…
sweetmantech added a commit that referenced this pull request Jun 30, 2026
* 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…
sweetmantech added a commit that referenced this pull request Jun 30, 2026
* 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…
sweetmantech added a commit that referenced this pull request Jun 30, 2026
* 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…
sweetmantech added a commit that referenced this pull request Jun 30, 2026
* 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…
sweetmantech added a commit that referenced this pull request Jul 2, 2026
* 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…
sweetmantech added a commit that referenced this pull request Jul 3, 2026
…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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant