Skip to content

docs(connectors): add POST /api/connectors/files (image upload contract)#246

Merged
sweetmantech merged 2 commits into
mainfrom
fix/connectors-files-upload-docs
Jun 20, 2026
Merged

docs(connectors): add POST /api/connectors/files (image upload contract)#246
sweetmantech merged 2 commits into
mainfrom
fix/connectors-files-upload-docs

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

What

Documents the new POST /api/connectors/files endpoint that stages an image into Composio storage so it can be attached to connector actions with file_uploadable fields (LinkedIn/X image posts). This is the contract half of recoupable/chat#1809 (docs → api).

Changes (api-reference/openapi/social.json, additive — 110 insertions, 0 deletions)

  • New POST /api/connectors/files path, mirroring the execute-action block: apiKey/bearer security, responses 200 / 400 / 401 / 502.
  • New schemas:
    • UploadConnectorFileRequest{ url (uri, required), toolSlug (required) }. (URL-only by decision; the SDK composio.files.upload takes { file, toolSlug, toolkitSlug } and infers name/mimetype, so no mimetype/filename inputs — accuracy over symmetry.)
    • UploadConnectorFileResponse — flat { success, name, mimetype, s3key } (no data wrapper, per repo convention).
  • upload-file.mdx reference page (frontmatter-only) + docs.json nav entry.

Flow it documents

POST /api/connectors/files { url, toolSlug }{ name, mimetype, s3key } → pass that into parameters.images[] on POST /api/connectors/actions with LINKEDIN_CREATE_LINKED_IN_POST.

Merge order: this docs PR → api PR (endpoint impl, TDD). cc recoupable/chat#1809

🤖 Generated with Claude Code


Summary by cubic

Documents POST /api/connectors/files to stage images in Connector storage for actions with file_uploadable fields. Upload by URL and use the returned { name, mimetype, s3key } in parameters.images[] when executing an action.

  • New Features
    • Added POST /api/connectors/files to api-reference/openapi/social.json with apiKey/bearer auth and 200/400/401/502 responses.
    • New schemas: UploadConnectorFileRequest (url, toolSlug) and UploadConnectorFileResponse (success, name, mimetype, s3key).
    • Added api-reference/connectors/upload-file.mdx and nav entry; updated Execute Connector Action description with a cross-link. Simplified wording and removed vendor names.

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

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a new endpoint to stage connector files from an image URL for use with subsequent connector actions.
  • Documentation

    • Added an API reference page for uploading connector files.
    • Updated the OpenAPI spec to include the new endpoint and request/response schemas, and to document how it integrates with file_uploadable parameters.

Adds the OpenAPI contract for the new connectors file-upload endpoint that
stages an image into Composio storage and returns a { name, mimetype, s3key }
descriptor for use in file_uploadable action fields (e.g.
LINKEDIN_CREATE_LINKED_IN_POST.images[]).

- social.json: new POST /api/connectors/files path (apiKey/bearer auth,
  200/400/401/502) + UploadConnectorFileRequest / UploadConnectorFileResponse
  schemas, mirroring the execute-action contract. Additive diff.
- upload-file.mdx reference page + docs.json nav entry.

Contract for recoupable/chat#1809.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d4e56854-558a-4521-a25e-9130f0aaf045

📥 Commits

Reviewing files that changed from the base of the PR and between 89bb4bc and f862885.

📒 Files selected for processing (1)
  • api-reference/openapi/social.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • api-reference/openapi/social.json

📝 Walkthrough

Walkthrough

A new POST /api/connectors/files endpoint is added to the OpenAPI spec with UploadConnectorFileRequest and UploadConnectorFileResponse schemas. The POST /api/connectors/actions endpoint description is updated to document the image staging workflow. A corresponding MDX reference page is created and registered in the Connectors navigation group.

Changes

Upload Connector File endpoint

Layer / File(s) Summary
OpenAPI endpoint, schemas, and reference documentation
api-reference/openapi/social.json, api-reference/connectors/upload-file.mdx, docs.json
Introduces UploadConnectorFileRequest (url, toolSlug) and UploadConnectorFileResponse (success, name, mimetype, s3key) schemas; adds POST /api/connectors/files path with apiKey/bearer auth, error responses (400/401/502), and success response; updates POST /api/connectors/actions description to document the image staging step via the new endpoint; creates the MDX page linking to the endpoint; registers the page in the Connectors navigation group.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~5 minutes

Possibly related issues

Possibly related PRs

  • recoupable/docs#167 — Both PRs update api-reference/openapi/social.json; this PR extends the POST /api/connectors/actions endpoint documentation to include the new image staging workflow via POST /api/connectors/files.

Poem

🐇 A hop, a skip, a new route appears,
/connectors/files now proudly steers!
With url and toolSlug tucked in tight,
An s3key gleams in the staging light.
The rabbit docs it, thumps with delight! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding documentation for a new POST /api/connectors/files endpoint for image uploads to the connector API.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 fix/connectors-files-upload-docs

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.

@mintlify

mintlify Bot commented Jun 19, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
recoup-docs 🟢 Ready View Preview Jun 19, 2026, 6:10 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@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 (1)
api-reference/openapi/social.json (1)

2391-2394: ⚡ Quick win

Validate toolSlug format in schema, not only in prose.

Line 2393 says UPPERCASE_SNAKE_CASE, but the schema currently accepts any string. Add a pattern so clients get deterministic validation feedback.

Suggested schema validation
           "toolSlug": {
             "type": "string",
+            "pattern": "^[A-Z0-9_]+$",
             "description": "The action slug the image will be attached to, UPPERCASE_SNAKE_CASE (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`). Scopes the upload to that tool/toolkit. Required."
           }
🤖 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 `@api-reference/openapi/social.json` around lines 2391 - 2394, The toolSlug
field in the schema currently only documents the UPPERCASE_SNAKE_CASE
requirement in the description but does not enforce it programmatically. Add a
pattern property to the toolSlug field definition that validates the
UPPERCASE_SNAKE_CASE format (e.g., using a regex pattern that matches uppercase
letters, numbers, and underscores) so that API clients receive deterministic
validation feedback rather than relying on prose documentation alone.
🤖 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 `@api-reference/connectors/upload-file.mdx`:
- Around line 1-4: The frontmatter metadata in the upload-file.mdx file is
missing a required `description` field. Add a `description` field to the
frontmatter block (between the triple dashes) along with the existing `title`
and `openapi` fields. The description should be a concise summary of what the
upload connector file endpoint does, following the docs metadata convention that
requires both title and description for all MDX pages.
- Line 3: The openapi frontmatter on line 3 is not in the required format. It
currently includes a file path before the HTTP method and endpoint, but the
correct format should only contain the HTTP method and the endpoint path using
the structure 'METHOD /path'. Remove the file path prefix
'/api-reference/openapi/social.json' from the openapi field and keep only 'POST
/api/connectors/files' using single quotes to match the required API-reference
format.

In `@api-reference/openapi/social.json`:
- Around line 2386-2389: The url property in the schema currently accepts any
URI format which creates SSRF exposure risk. Modify the url property definition
to restrict the format to only HTTP and HTTPS schemes by changing the format
constraint and updating the description to explicitly state that only http://
and https:// schemes are allowed and that private/local IP ranges and hostnames
are rejected. Ensure the description clearly communicates these restrictions to
API consumers.

---

Nitpick comments:
In `@api-reference/openapi/social.json`:
- Around line 2391-2394: The toolSlug field in the schema currently only
documents the UPPERCASE_SNAKE_CASE requirement in the description but does not
enforce it programmatically. Add a pattern property to the toolSlug field
definition that validates the UPPERCASE_SNAKE_CASE format (e.g., using a regex
pattern that matches uppercase letters, numbers, and underscores) so that API
clients receive deterministic validation feedback rather than relying on prose
documentation alone.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ff5cedc7-d13f-4068-92ff-0dada6187074

📥 Commits

Reviewing files that changed from the base of the PR and between 5d7ae3c and 89bb4bc.

📒 Files selected for processing (3)
  • api-reference/connectors/upload-file.mdx
  • api-reference/openapi/social.json
  • docs.json

Comment on lines +1 to +4
---
title: 'Upload Connector File'
openapi: "/api-reference/openapi/social.json POST /api/connectors/files"
---

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 | 🟡 Minor | ⚡ Quick win

Add a description field in frontmatter metadata.

This page currently has title but no description, which breaks the docs metadata convention for MDX pages.

Suggested metadata addition
 ---
 title: 'Upload Connector File'
+description: 'Stage an external image URL for connector actions that accept file_uploadable inputs.'
 openapi: "POST /api/connectors/files"
 ---

As per coding guidelines, “Use MDX … with frontmatter for page metadata (title, description).”

📝 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
---
title: 'Upload Connector File'
openapi: "/api-reference/openapi/social.json POST /api/connectors/files"
---
---
title: 'Upload Connector File'
description: 'Stage an external image URL for connector actions that accept file_uploadable inputs.'
openapi: "POST /api/connectors/files"
---
🤖 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 `@api-reference/connectors/upload-file.mdx` around lines 1 - 4, The frontmatter
metadata in the upload-file.mdx file is missing a required `description` field.
Add a `description` field to the frontmatter block (between the triple dashes)
along with the existing `title` and `openapi` fields. The description should be
a concise summary of what the upload connector file endpoint does, following the
docs metadata convention that requires both title and description for all MDX
pages.

Source: Coding guidelines

@@ -0,0 +1,4 @@
---
title: 'Upload Connector File'
openapi: "/api-reference/openapi/social.json POST /api/connectors/files"

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

Use the required API-reference openapi format.

Line 3 is not in the required openapi: 'METHOD /path' shape, so Mintlify auto-generation may not bind this page correctly.

Suggested frontmatter fix
-openapi: "/api-reference/openapi/social.json POST /api/connectors/files"
+openapi: "POST /api/connectors/files"

As per coding guidelines, “API reference MDX pages should be frontmatter-only … Include OpenAPI spec reference in API reference MDX frontmatter using format: openapi: 'METHOD /path'.”

📝 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
openapi: "/api-reference/openapi/social.json POST /api/connectors/files"
openapi: "POST /api/connectors/files"
🤖 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 `@api-reference/connectors/upload-file.mdx` at line 3, The openapi frontmatter
on line 3 is not in the required format. It currently includes a file path
before the HTTP method and endpoint, but the correct format should only contain
the HTTP method and the endpoint path using the structure 'METHOD /path'. Remove
the file path prefix '/api-reference/openapi/social.json' from the openapi field
and keep only 'POST /api/connectors/files' using single quotes to match the
required API-reference format.

Source: Coding guidelines

Comment thread api-reference/openapi/social.json Outdated
Comment on lines +2386 to +2389
"url": {
"type": "string",
"format": "uri",
"description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."

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

Constrain url beyond generic uri to reduce SSRF exposure.

Line 2388 currently accepts any URI scheme; this contract allows non-HTTP schemes and ambiguous hosts for a server-side fetch flow. Tighten the schema and description to https? only and explicitly reject private/local targets.

Suggested spec hardening
           "url": {
             "type": "string",
             "format": "uri",
-            "description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."
+            "pattern": "^https?://",
+            "description": "Publicly reachable HTTP(S) URL of the image to stage. Fetched server-side and uploaded to Composio storage. Private/internal IP ranges and localhost are rejected. Required."
           },
📝 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
"url": {
"type": "string",
"format": "uri",
"description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."
"url": {
"type": "string",
"format": "uri",
"pattern": "^https?://",
"description": "Publicly reachable HTTP(S) URL of the image to stage. Fetched server-side and uploaded to Composio storage. Private/internal IP ranges and localhost are rejected. Required."
}
🤖 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 `@api-reference/openapi/social.json` around lines 2386 - 2389, The url property
in the schema currently accepts any URI format which creates SSRF exposure risk.
Modify the url property definition to restrict the format to only HTTP and HTTPS
schemes by changing the format constraint and updating the description to
explicitly state that only http:// and https:// schemes are allowed and that
private/local IP ranges and hostnames are rejected. Ensure the description
clearly communicates these restrictions to API consumers.

@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 3 files

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="api-reference/openapi/social.json">

<violation number="1" location="api-reference/openapi/social.json:2388">
P2: The `url` field accepts any URI scheme via `format: "uri"`, but this endpoint fetches the URL server-side. Add a `pattern: "^https?://"` constraint to reject non-HTTP schemes (e.g., `file://`, `ftp://`) and reduce SSRF surface area.</violation>

<violation number="2" location="api-reference/openapi/social.json:2391">
P2: Field named `toolSlug` but described as "the action slug" — inconsistent with `actionSlug` used by the companion ExecuteConnectorActionRequest. Consumers must use both endpoints together (upload file, then pass result to execute action) and will be confused by the different names for the same UPPERCASE_SNAKE_CASE identifier.</violation>
</file>

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

Re-trigger cubic

"format": "uri",
"description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."
},
"toolSlug": {

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: Field named toolSlug but described as "the action slug" — inconsistent with actionSlug used by the companion ExecuteConnectorActionRequest. Consumers must use both endpoints together (upload file, then pass result to execute action) and will be confused by the different names for the same UPPERCASE_SNAKE_CASE identifier.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api-reference/openapi/social.json, line 2391:

<comment>Field named `toolSlug` but described as "the action slug" — inconsistent with `actionSlug` used by the companion ExecuteConnectorActionRequest. Consumers must use both endpoints together (upload file, then pass result to execute action) and will be confused by the different names for the same UPPERCASE_SNAKE_CASE identifier.</comment>

<file context>
@@ -2309,6 +2375,50 @@
+            "format": "uri",
+            "description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."
+          },
+          "toolSlug": {
+            "type": "string",
+            "description": "The action slug the image will be attached to, UPPERCASE_SNAKE_CASE (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`). Scopes the upload to that tool/toolkit. Required."
</file context>

Comment thread api-reference/openapi/social.json Outdated
Comment on lines +2388 to +2389
"format": "uri",
"description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."

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: The url field accepts any URI scheme via format: "uri", but this endpoint fetches the URL server-side. Add a pattern: "^https?://" constraint to reject non-HTTP schemes (e.g., file://, ftp://) and reduce SSRF surface area.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api-reference/openapi/social.json, line 2388:

<comment>The `url` field accepts any URI scheme via `format: "uri"`, but this endpoint fetches the URL server-side. Add a `pattern: "^https?://"` constraint to reject non-HTTP schemes (e.g., `file://`, `ftp://`) and reduce SSRF surface area.</comment>

<file context>
@@ -2309,6 +2375,50 @@
+        "properties": {
+          "url": {
+            "type": "string",
+            "format": "uri",
+            "description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."
+          },
</file context>
Suggested change
"format": "uri",
"description": "Publicly reachable URL of the image to stage. Fetched server-side and uploaded to Composio storage. Required."
"format": "uri",
"pattern": "^https?://",
"description": "Publicly reachable HTTP(S) URL of the image to stage. Fetched server-side and uploaded to Composio storage. Private/internal IP ranges and localhost are rejected. Required."

Comment thread api-reference/openapi/social.json Outdated
Comment thread api-reference/openapi/social.json Outdated
Comment thread api-reference/openapi/social.json Outdated
Comment thread api-reference/openapi/social.json Outdated
Comment thread api-reference/openapi/social.json Outdated
…links

- Replace third-party vendor name (Composio) with 'Connector' in all
  /api/connectors/files descriptions.
- Simplify descriptions (KISS): drop action-slug examples and the long
  staging explanation from the path + s3key descriptions.
- Add a bidirectional hyperlink between Upload Connector File and Execute
  Connector Action for easier dev reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sweetmantech sweetmantech merged commit f2d9387 into main Jun 20, 2026
3 checks passed
sweetmantech added a commit to recoupable/api that referenced this pull request Jun 20, 2026
…dIn/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>
sweetmantech added a commit to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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 to recoupable/api 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…
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