Skip to content

feat(apify): any authenticated key can poll GET /api/apify/runs/{runId} (chat#1840)#752

Merged
sweetmantech merged 65 commits into
mainfrom
feat/apify-runs-account-scope
Jul 3, 2026
Merged

feat(apify): any authenticated key can poll GET /api/apify/runs/{runId} (chat#1840)#752
sweetmantech merged 65 commits into
mainfrom
feat/apify-runs-account-scope

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Item 1 of recoupable/chat#1840. No docs or database dependency — docs#262 and database#39 both closed unmerged per the design decision below (the existing docs page already matches the new behavior).

Why

An account-scoped key can start a scrape via POST /api/socials/{id}/scrape but got Forbidden polling its own run — validateGetScraperResultsRequest was admin-only (7/7 Forbidden polls in the issue's production evidence, while docs/skills/task prompts all direct agents to poll).

Decision (2026-07-03, supersedes the owner-or-admin draft)

Any valid API key or Bearer token can poll any run id. Scrape datasets are public social content and Apify run ids are long random strings; possession + a valid credential is sufficient. Authentication still gates the endpoint so anonymous callers can't use it as a free Apify proxy. Admin behavior is unchanged (admins hold valid credentials); backend pollers unaffected.

What

One swap in validateGetScraperResultsRequest: validateAdminAuthvalidateAuthContext. 401 (no/both credentials), 400 (bad runId), 200/500 all unchanged in shape. Final diff: 2 files, +17/−19.

Tests (TDD, RED→GREEN)

Validator suite rewritten for the new contract: auth-error propagation, 400 empty runId, any-authenticated-caller passthrough. Full lib/apify + lib/socials suites: 117 passed; tsc --noEmit no new errors vs main; eslint clean.

Done-when — preview verification to follow as a comment

  • an account key polls a real runId to completion (no admin token involved) — verified on preview 2026-07-03, results table in comments; scrape-start with an account key was already working in prod (this PR only changes the poll validator)

🤖 Generated with Claude Code

sweetmantech and others added 30 commits June 16, 2026 11:40
…ckfill 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.
…t#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.
…97) (#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).
…t) (#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>
…e 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
…(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>
…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>
… 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>
#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>
…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>
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>
…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>
* 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>
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>
…y (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>
# Conflicts:
#	lib/chat/buildRunAgentInput.ts
…3) (#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>
sweetmantech and others added 7 commits July 2, 2026 10:09
Adds an optional `posts` parameter (integer, 1-100, default: legacy
single-item snapshot) to POST /api/socials/{id}/scrape (query param) and
POST /api/artist/socials/scrape (body field), threaded through
scrapeProfileUrl/scrapeProfileUrlBatch into the platform actor inputs:

- Twitter (apidojo/twitter-scraper-lite): maxItems = posts ?? 1 — returns
  the last N tweets with engagement metrics incl. viewCount (impressions)
- YouTube (streamers/youtube-scraper): maxResults = posts ?? 1 and
  maxResultsShorts = posts ?? 0 — a requested depth includes Shorts,
  which the legacy snapshot explicitly excluded
- Other platforms accept and ignore the param (Instagram profile scrapes
  already bundle latestPosts)

Omitting posts produces the exact same actor inputs as before, so existing
profile-snapshot callers and their Apify cost profile are unchanged.

Implements recoupable/chat#1836; contract documented in recoupable/docs#258.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Agents that start a scrape with an account key could never read the
result: the results endpoint was admin-only because Apify run ids carry
no account scope (7/7 Forbidden polls in the issue evidence). Now the
scrape-start handler records run ownership in apify_scraper_runs, and
the results validator authorizes owner-or-admin:

- admin: any run id, unchanged (backend pollers keep working)
- owner: runs it started via POST /api/socials/{id}/scrape
- foreign run (non-admin): 403 Forbidden
- no recorded owner (non-admin): 404 Run not found — covers pre-tracking
  runs and runs started from other endpoints, without leaking existence

Ownership insert logs-and-continues on failure so a mapping miss only
degrades that run to admin-only polling, never fails the scrape.

Depends on recoupable/database#39 (creates apify_scraper_runs).
Contract: recoupable/docs#262. types/database.types.ts block is
hand-added to match the generator output; re-generate after the
migration applies.

TDD: validator suite rewritten RED->GREEN (auth propagation, 400, admin
passthrough, owner passthrough, foreign 403, unknown 404, null-lookup
404); scrape handler gains ownership-recording tests. Full
lib/apify+lib/socials+lib/supabase suites green (279 tests); tsc adds no
new errors over main; eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 3, 2026 8:43pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 27d98e51-4e94-416b-aa3d-e91998eaae9f

📥 Commits

Reviewing files that changed from the base of the PR and between 7faeee1 and 2c1911e.

⛔ Files ignored due to path filters (1)
  • lib/apify/__tests__/validateGetScraperResultsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/apify/validateGetScraperResultsRequest.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/apify-runs-account-scope

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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 7 files

Confidence score: 3/5

  • In lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts, a rejected ownership insert promise can bypass the current { error } handling and fail scrape start unexpectedly, so users may see intermittent start failures instead of controlled error handling — wrap the insert path in try/catch before merging.
  • In lib/apify/validateGetScraperResultsRequest.ts, collapsing Supabase/query errors (null) and true misses ([]) into the same !run branch can return 404 Run not found for backend failures, which misleads clients and complicates incident triage — separate error vs not-found handling and return the appropriate status/message.
  • In types/database.types.ts, missing account_id and social_id foreign-key relationship declarations create schema/type inconsistency versus other tables, increasing the chance of incorrect joins or weaker type safety in generated query code — add the missing account_id -> accounts.id and social_id -> socials.id relationships before merge.
Architecture diagram
sequenceDiagram
    participant Client
    participant ScrapeAPI as POST /api/socials/{id}/scrape
    participant Auth as validateAuthContext
    participant ScrapeHandler as postSocialScrapeHandler
    participant ApifySvc as scrapeProfileUrl
    participant RunDB as apify_scraper_runs
    participant PollAPI as GET /api/apify/runs/{runId}
    participant CheckAdmin as checkIsAdmin

    Note over Client,PollAPI: Account-scoped Apify run ownership & authorization

    Client->>ScrapeAPI: POST /api/socials/{id}/scrape
    ScrapeAPI->>Auth: validateAuthContext(request)
    Auth-->>ScrapeAPI: accountId
    ScrapeAPI->>ScrapeHandler: postSocialScrapeHandler(request, id)
    ScrapeHandler->>ApifySvc: scrapeProfileUrl(...)
    alt Scrape started successfully
        ApifySvc-->>ScrapeHandler: { runId, datasetId }
        Note over ScrapeHandler: NEW: Record ownership
        ScrapeHandler->>RunDB: insertApifyScraperRun({run_id, account_id, social_id})
        Note over ScrapeHandler,RunDB: log-and-continue on error
        RunDB-->>ScrapeHandler: inserted row or null
        ScrapeHandler-->>ScrapeAPI: 200 { runId }
    else Scrape fails to start
        ApifySvc-->>ScrapeHandler: { error: "..." }
        Note over ScrapeHandler: NEW: No ownership recorded
        ScrapeHandler-->>ScrapeAPI: error response
    end

    Client->>PollAPI: GET /api/apify/runs/{runId}
    PollAPI->>Auth: validateAuthContext(request)
    Auth-->>PollAPI: accountId
    PollAPI->>CheckAdmin: checkIsAdmin(accountId)
    alt Admin
        CheckAdmin-->>PollAPI: true
        Note over PollAPI: No ownership lookup for admin
        PollAPI-->>Client: 200 (run data, unchanged)
    else Non-admin
        CheckAdmin-->>PollAPI: false
        PollAPI->>RunDB: selectApifyScraperRuns({runId})
        alt Owner record found and matches caller
            RunDB-->>PollAPI: [{run_id, account_id, ...}]
            PollAPI-->>Client: 200 (run data)
        else Owner record found for different account
            RunDB-->>PollAPI: [{run_id, account_id, …}]
            PollAPI-->>Client: 403 { status:"error", message:"Forbidden" }
        else No owner record (empty or null)
            RunDB-->>PollAPI: [] or null
            PollAPI-->>Client: 404 { status:"error", message:"Run not found" }
        end
    end
Loading

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

Re-trigger cubic

Comment thread lib/supabase/apify_scraper_runs/insertApifyScraperRun.ts Outdated
Comment thread types/database.types.ts Outdated
Comment thread lib/apify/validateGetScraperResultsRequest.ts Outdated
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — round 1 (2026-07-03)

Preview https://api-qh1womekr-recoup.vercel.app confirmed built from cbb4d7a7 (deployment 5303721407 queried by sha, status success).

check expected observed verdict
GET /api/apify/runs/{runId}, no credentials 401 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"} — the new validateAuthContext-first path is live (old code returned the admin 403 here)
prod control: fresh non-admin key on prod endpoint 403 (old admin-only behavior) 403 {"status":"error","message":"Forbidden"} ✅ baseline
fresh non-admin key on preview 404 Run not found 401 Unauthorized — the key (minted via prod /api/agents/signup, verified working on prod /api/accounts/id → 200) is unknown to the preview ⚠️ blocked

The third row is an environment finding, not a code failure: api preview deployments don't share the prod key store — the same key returns 200 on prod and 401 on the #753 preview too, whose auth path is untouched. Account-scoped checks (owner 200 / foreign 403 / unmapped 404) therefore can't be exercised against a preview with a minted key.

Remaining Done-when checks and their gates:

  • owner 200 + foreign 403 + unmapped 404 — needs database#39 applied + an environment that shares the key store (the test deployment after merge, or a preview-env admin token).
  • admin 200 — needs a fresh admin token (the session token expired mid-verification; the unit suite covers the passthrough).

Unit-level coverage of all five outcomes is in the PR (RED→GREEN). Will post round 2 once the migration lands.

…ision)

Design decision 2026-07-03 (supersedes the owner-or-admin draft): scrape
datasets are public social content and Apify run ids are unguessable, so
possession of a runId plus any valid API key / Bearer token is
sufficient. validateAdminAuth -> validateAuthContext; drops the
apify_scraper_runs ownership map, the scrape-start insert, and the
403/404 paths. recoupable/database#39 is closed unmerged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sweetmantech sweetmantech changed the title feat(apify): account-scope GET /api/apify/runs/{runId} (chat#1840) feat(apify): any authenticated key can poll GET /api/apify/runs/{runId} (chat#1840) Jul 3, 2026
@sweetmantech

sweetmantech commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Reworked per design discussion (2026-07-03): moved from owner-or-admin to any authenticated caller — the maintainer's call: scrape data is public social content and run ids are unguessable, so possession + a valid credential is sufficient. The apify_scraper_runs ownership map is gone (database#39 closed unmerged), the diff is now 2 files (+17/−19), and the round-1 preview findings about key-store scoping no longer gate anything except the final live poll check.

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

0 issues found across 7 files (changes from recent commits).

Auto-approved: Swaps admin-only auth to any-valid-credential auth for GET /api/apify/runs/{runId}. Isolated change with updated tests and clear design rationale.

Re-trigger cubic

@sweetmantech

sweetmantech commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Preview verification — round 2 (2026-07-03) ✅

Preview https://api-ou561uuwp-recoup.vercel.app confirmed built from the reworked head 6563b83d (deployment 5304134290, queried by sha, state success).

Credential setup: previews share the production Supabase but hash API keys with a preview-specific PRIVY_PROJECT_SECRET (so prod-minted keys never match) and use a separate Privy app — corrected after checking list_projects: godremdq… is the only Supabase project, and POST /api/agents/signup on the preview 500s — preview-env Privy is at its user cap (createPrivyUser → 400 max_accounts_reached, from live function logs; account row is created, key never minted — pre-existing env issue, unrelated to this PR). Workaround: created the account + hashed key rows directly in the preview Supabase via its own service role and PRIVY_PROJECT_SECRET from vercel env pull — byte-identical to what generateAndStoreApiKey writes, skipping only the broken Privy step. Test account: 6c8cec0b-f1b2-40a2-b10f-ddd60f91bb3a (plain account, no admin/org membership).

# check expected observed verdict
1 GET /api/accounts/id with minted key 200, own accountId 200 {"accountId":"6c8cec0b-…"}
2 Done-when: plain account key polls real TikTok run 9AYX8xyaHWyHtnGtC 200 + data 200 status:SUCCEEDED, dataset wRTf1l626BDzubDNw, 1 item, webVideoUrl: tiktok.com/@brauxelion/video/7516257593208147205
3 same key polls real X run bx3asRqfbNnkKgogG 200 + data 200 status:SUCCEEDED, 1 item
4 no credentials 401 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
5 unknown runId, valid key (record actual) 200 {"status":"UNKNOWN","dataset_id":null} — pre-existing handler behavior, unchanged by this PR ✅ documented
6 prod control (earlier this session): non-admin key, same poll on prod 403 (old admin gate) 403 {"status":"error","message":"Forbidden"} ✅ baseline

Row 2 is the exact call from the issue's evidence (7/7 Forbidden in production on 2026-07-02) now returning the run's real dataset to a plain account key. Docs ↔ API ↔ live agree: 200/400/500 per the existing Scraper Results page, 401 for anonymous callers (undocumented, consistent with sibling endpoints).

Two inherited env findings, out of scope here: preview Privy app at max_accounts_reached breaks POST /api/agents/signup on previews; previews don't share the prod key store (expected, but worth knowing for future preview testing).

@sweetmantech sweetmantech changed the base branch from test to main July 3, 2026 20:36
@sweetmantech sweetmantech merged commit 2f59abc into main Jul 3, 2026
4 of 6 checks passed
@sweetmantech sweetmantech deleted the feat/apify-runs-account-scope branch July 3, 2026 20:42
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