You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tracking issue for hardening how the platform grants authority to act on an artist's behalf — specifically, decoupling artist association (an artist showing up in an account's roster/catalog, for analytics) from act-as-artist authority (using that artist's authenticated OAuth connectors to publish or take private actions). Surfaced 2026-07-08 while reviewing the Catalog HQ funnel work (chat#1859): the funnel's claim-time canonical-artist attach (api#768, merged) writes the same account_artist_ids roster row that today doubles as full connector authority — so pointing funnel accounts at the connectable canonical widens a pre-existing privilege-escalation vector. (Business context, once: connectors are how customers publish to their real social accounts; a cross-account leak here is a direct customer-trust incident, so this gates how far we scale connector adoption.)
Status (2026-07-09): the acute objective is complete and verified in prod — this issue is CLOSED. The five shipped PRs below (P0b, P0a, deleteArtist guard, docs contract, join-row UNIQUE constraints) closed the live cross-account act-as-artist vector, the unauthenticated roster-write hole, and the shared-canonical data-loss path. The non-acute connection-keying redesign and the PATCH metadata gate were split to follow-up trackers — see the dated decision callout below. Follow-ups: chat#1865 (re-key + org-union + P2 audit/wipe) and chat#1866 (PATCH gate).
Decision (sweetman, 2026-07-08): connections will be scoped per (account, artist) and per (org, artist) by keying them on the existing join rows (account_artist_ids / artist_organization_ids) — not on the bare canonical artist account. Artists stay canonical/shared; associating a catalog stays free; using a connector is private to the account (or org) that established it. Migration approach: wipe all existing Composio connections and reconnect fresh in the new paradigm (believed near-zero live usage — audit first).
Decision (sweetman, 2026-07-08): PR matrix reordered by customer impact.P0b now leads — it severs the live cross-account act-as-artist vector (canonical roster links are already accumulating on prod via api#768, so this is active exposure, not theoretical). P0a ships second (the unauthenticated write is the door; P0b makes what's behind it non-authoritative). The deleteArtist guard is promoted above the P1 re-key chain: it is independent of the redesign and protects against irreversible data loss (a hard-deleted shared canonical cascades song_artists). P1 internal sequencing (docs → database → api) is unchanged.
Decision — scope narrowed + closed (sweetman, 2026-07-09): the acute objective of this issue is complete and verified in prod — P0b (api#772), P0a (api#771), and the deleteArtist guard (api#770) are all merged to main and live-verified, and the contract (docs#271) + join-row UNIQUE constraints (database#46, applied to prod) shipped. The remaining P1/P2 work is not acute (P0b already blocks cross-account connector use): it is a storage-keying redesign + a metadata-listing leak + an org-sharing feature + a destructive migration. Split to follow-up trackers so this issue can close: the connection-keying redesign (re-key + org-union + P2 audit/wipe) → chat#1865; the independent PATCH /api/artists metadata gate → chat#1866. Not abandoned — relocated.
Goal
Roster/catalog association grants read/analytics only. Authority to authorize, use, or disconnect an artist's OAuth connectors — and to take any act-as-artist action — is scoped to the account (or org) that owns the connection, and cannot be obtained merely by having the artist in a roster. Concretely, when done:
No code path grants connector authorize/execute/disconnect off a flat account_artist_ids roster row.
Composio connections are keyed on the join row (account_artist_ids / artist_organization_ids), so getConnectors/getComposioTools for artist A return only the caller's own (or their org's) connections for A — never another account's.
POST /api/accounts/artists requires authentication (today it is fully open).
deleteArtist can never hard-delete a shared canonical account that other accounts/song graphs depend on.
A full matrix of existing Composio connections (account × artist × toolkit) exists, and the wipe-and-reconnect migration is executed against it.
Done at the macro level when: an account holding artist A in its roster but owning no connection for A gets zero connector tools for A (chat agent and POST /api/connectors/actions), verified live; and a second account in the same org, only if explicitly org-shared, can use A's org connection — nobody else can.
PRs (updated 2026-07-08 — reordered by customer impact, top = ship first; all to-open, none exist yet)
P0b: connector authorize/execute + getComposioTools artist branch stop granting off the flat account_artist_ids edge (interim: self / org co-membership only)
main
✅ merged 2026-07-09 (fc1e0d7 → main) — new checkConnectorAuthority (fails closed) at all 4 call sites; checkAccountArtistAccess untouched (read/analytics keep roster access); TDD RED→GREEN, 272 tests pass. Preview-verified live both directions: non-staff roster-only account → 403 on authorize/execute/disconnect for the rostered artist (comment); staff/self grants + roster read paths intact (comment). Retroactively neutralizes every funnel roster link as connector authority
P1: connector contract — connection ownership keyed per (account, artist) / (org, artist); authorize/execute/disconnect authority model
main
✅ merged 2026-07-09 (668158d → main) — concept page connectors/ownership.mdx + Concepts nav group; flags (not rewrites) the shipped account-scoped OpenAPI descriptions; join-row-id-vs-pair deliberately undocumented. Verified on local mint dev: page renders, nav wired, callouts/tables/anchors/links resolve, contract reconciles with the live P0b 403 behavior. First of the re-key chain landed
P1:UNIQUE(account_id, artist_id) on account_artist_ids (+ UNIQUE(artist_id, organization_id) on artist_organization_ids) — prerequisite for join-row keying
main
✅ merged + applied to prod 2026-07-09 — dedupe-first, idempotent (pg_constraint-guarded). Verified via Supabase MCP (before/after): both UNIQUE constraints now present with correct defs; recorded as 20260708200000 / join_row_unique_constraints (no version drift); 0 duplicate pairs pre/post so dedupe removed nothing; live duplicate insert now rejected with 23505 (verification). Unblocks the api re-key
P1 lower (moved): gate PATCH /api/artists metadata writes off the flat roster edge
main
➡️ moved to chat#1866 — independent, lower severity
Merge sequencing (customer-impact order, 2026-07-08):P0b ships first — it severs roster→connector authority, which retroactively neutralizes every canonical roster link already accumulating on prod (the claim-time attach api#768 deployed 1b8012b, 2026-07-08 13:47 UTC; marketing#46 does not widen that exposure). P0a second, independent — it closes the unauthenticated roster-write door. The deleteArtist guard is independent of the redesign and ships early (data-loss protection). Then the P1 re-key chain in docs → database → api order (contract, then the UNIQUE constraint the keying depends on, then the re-key + org-union + metadata-gate). P2 ops last: run the audit, then the wipe-and-reconnect against the new keying. The funnel (chat#1859) can proceed regardless — P0b is remediation, not a gate on it.
P0 — acute (done, verified in prod)
P0b: connector authority is granted off the flat account_artist_ids roster edge. ✅ 2026-07-09 (→ api#772, merged fc1e0d7 → main) — Verified live: a non-staff account holding artist A only in its roster (no connection, no org tie) gets 403 from POST /api/connectors/actions, POST /api/connectors, and DELETE /api/connectors targeting A, while its own connectors and roster read/analytics stay functional; staff/self grants preserved (denial run · grant run). Chat-agent getComposioTools artist branch covered by the same checkConnectorAuthority cut (unit-tested).
Why:getComposioTools' artist branch (lib/composio/toolRouter/getComposioTools.ts:48-49) and the connector authorize/execute/disconnect validators (via checkAccountAccess → checkAccountArtistAccess, which returns true on a bare account_artist_ids row, lib/artists/checkAccountArtistAccess.ts:26-28) all treat roster membership as authority to use the artist's connectors. POST /api/connectors/actions additionally accepts an arbitrary account_id override gated by the same check (lib/composio/connectors/validateExecuteConnectorActionRequest.ts:49-58).
Fix (interim, pre-redesign): narrow the connector authorize/execute/disconnect and getComposioTools artist branch to require self or org co-membership — stop reading the account_artist_ids roster edge — until the full connection-keying lands. This makes any funnel/claim roster link non-authoritative by construction.
Done when: an account with artist A only in its roster (no connection, no org tie) gets zero connector tools for A in the chat agent and a 403 from POST /api/connectors/actions targeting A; verified live.
Review notes from api#772 (2026-07-08): (1) the new checkConnectorAuthority keeps the workspace-ownership and org-target grant paths from checkAccountAccess (they're membership edges, not the roster defect) — reviewer to confirm or tighten to strict self/org-co-member; (2) GET /api/connectors?account_id= still uses the flat check, so a roster-only account can still list (not use) another account's connections for a shared artist — decide whether that metadata leak joins this scope or the P1 re-key.
P0a: POST /api/accounts/artists is completely unauthenticated. ✅ 2026-07-09 (→ api#771, merged 69aa286 → main) — Verified live: the endpoint now 401s without a valid credential (previously fully open); an authenticated caller adds only to its own account (idempotent, no unintended write), an email override to a foreign account is 403 and to an unknown email 404 (verification). Follow-up remaining: docs update (add-artist.mdx) documenting auth + 401/403/404.
Why: the route (app/api/accounts/artists/route.ts) never calls validateAuthContext; the body is only {email, artistId} (lib/accounts/validateAddArtistBody.ts), and the handler resolves the account purely from the supplied email then writes insertAccountArtistId(accountId, artistId) (lib/accounts/addArtistToAccountHandler.ts). The only middleware is the x402 image-gen paywall. So anyone can link any account to any existing canonical artist — and under today's flat model, that roster row is act-as-artist authority. Exploitable today, no funnel needed.
Fix: require validateAuthContext; derive the account from the authenticated credential (or gate a body override via checkAccountAccess), never from an unauthenticated email.
Done when: the endpoint 401s without a valid credential; an authenticated caller can only add artists to an account it controls; verified live.
P1 (data-loss guard — done; redesign moved out)
P1 (promoted 2026-07-08): deleteArtist must not hard-delete a shared canonical. ✅ 2026-07-09 (→ api#770, merged 6bdcff7 → main) — Verified live: as the sole roster link of a real canonical with 2 song_artists rows, DELETE /api/artists/{id} returned 200 and the canonical account + both song_artists rows survived (pre-guard, that cascade would have destroyed them); the no-dependency case still hard-deletes (guard run). Guard fails closed on query error. Shipped with the generic selectSongArtists({ songs?, artists? }) lib per review.
Why:lib/artists/deleteArtist.ts removes only the caller's own link (deleteAccountArtistId(requester, artistId), scoped correctly), but then if zero roster links remain across all accounts it calls deleteAccountById(artistId) — hard-deleting the canonical account, which cascades song_artists (account_artist_ids.artist_id is ON DELETE CASCADE). A canonical with a catalog/song graph but a single roster link gets destroyed when that one account removes it. Independent of the re-key — can ship any time.
Fix: never hard-delete a canonical that has song_artists/catalog dependencies (arguably never hard-delete a shared canonical — only drop the caller's link).
Done when: removing the last roster link from a canonical that has song_artists leaves the canonical + its song graph intact; verified.
➡️ P1 connection-keying redesign + P2 ops — moved to chat#1865. The docs (docs#271) and database (database#46, applied to prod) prerequisites shipped as part of this issue. The remaining api re-key (ownerId → join row, with the join-row-id-vs-(account_id, artist_id)-pair design decision still open), the org-shared connection union, and the P2 Composio audit + wipe-and-reconnect migration are tracked there. Not acute — P0b already blocks cross-account connector use.
➡️ PATCH /api/artists shared-metadata gate — moved to chat#1866 (independent, lower severity; lib/artists/validateUpdateArtistRequest.ts:88 → checkAccountArtistAccess).
Architecture decisions
Association ≠ authority.account_artist_ids (and org/workspace membership) grants read/analytics only. Act-as-artist authority (connectors, publish, disconnect, destructive artist writes) is separate and connection-scoped. (sweetman, 2026-07-08)
Artists stay canonical/shared; connections are scoped, not artists. No per-signup artist duplication (that's the funnel P1 in chat#1859); isolation lives on the connection via the join-row ownerId, not by forking the artist.
Connection ownership is self-anchored, keyed on the join row. Private by default to (account, artist); explicitly shareable to (org, artist). Whoever establishes a connection owns it; roster membership never inherits it. (Supersedes the earlier "OAuth completion ⇒ artist manager" idea, which was wrong — an OAuth handshake proves control of the connected social account, not authority over the canonical artist.)
Admin override stays, admin-only. The account_id override on connector endpoints is retained for Recoup-staff support, gated on RECOUP_ORG admin — not on the flat roster edge.
Migration = wipe + reconnect, not attribute-and-remap (no establisher recorded in Composio; near-zero live usage). (sweetman, 2026-07-08)
Tracking issue for hardening how the platform grants authority to act on an artist's behalf — specifically, decoupling artist association (an artist showing up in an account's roster/catalog, for analytics) from act-as-artist authority (using that artist's authenticated OAuth connectors to publish or take private actions). Surfaced 2026-07-08 while reviewing the Catalog HQ funnel work (chat#1859): the funnel's claim-time canonical-artist attach (api#768, merged) writes the same
account_artist_idsroster row that today doubles as full connector authority — so pointing funnel accounts at the connectable canonical widens a pre-existing privilege-escalation vector. (Business context, once: connectors are how customers publish to their real social accounts; a cross-account leak here is a direct customer-trust incident, so this gates how far we scale connector adoption.)Status (2026-07-09): the acute objective is complete and verified in prod — this issue is CLOSED. The five shipped PRs below (P0b, P0a,
deleteArtistguard, docs contract, join-rowUNIQUEconstraints) closed the live cross-account act-as-artist vector, the unauthenticated roster-write hole, and the shared-canonical data-loss path. The non-acute connection-keying redesign and thePATCHmetadata gate were split to follow-up trackers — see the dated decision callout below. Follow-ups: chat#1865 (re-key + org-union + P2 audit/wipe) and chat#1866 (PATCH gate).Goal
Roster/catalog association grants read/analytics only. Authority to authorize, use, or disconnect an artist's OAuth connectors — and to take any act-as-artist action — is scoped to the account (or org) that owns the connection, and cannot be obtained merely by having the artist in a roster. Concretely, when done:
account_artist_idsroster row.account_artist_ids/artist_organization_ids), sogetConnectors/getComposioToolsfor artist A return only the caller's own (or their org's) connections for A — never another account's.POST /api/accounts/artistsrequires authentication (today it is fully open).deleteArtistcan never hard-delete a shared canonical account that other accounts/song graphs depend on.Done at the macro level when: an account holding artist A in its roster but owning no connection for A gets zero connector tools for A (chat agent and
POST /api/connectors/actions), verified live; and a second account in the same org, only if explicitly org-shared, can use A's org connection — nobody else can.PRs (updated 2026-07-08 — reordered by customer impact, top = ship first; all to-open, none exist yet)
getComposioToolsartist branch stop granting off the flataccount_artist_idsedge (interim: self / org co-membership only)mainfc1e0d7→main) — newcheckConnectorAuthority(fails closed) at all 4 call sites;checkAccountArtistAccessuntouched (read/analytics keep roster access); TDD RED→GREEN, 272 tests pass. Preview-verified live both directions: non-staff roster-only account → 403 on authorize/execute/disconnect for the rostered artist (comment); staff/self grants + roster read paths intact (comment). Retroactively neutralizes every funnel roster link as connector authorityPOST /api/accounts/artists(unauthenticated roster-write hole)main69aa286→main) — TDD RED→GREEN, 60 tests pass;emailsurvives as acheckAccountAccess-gated override. Preview-verified live: no credential → 401 (was open);400bad body,200idempotent add (no row written),404unknown email,403foreign-account email override (verification). SRP-refactored to route→handler→validator→business per review + re-verified (refactor run). Still open: docs update (add-artist.mdx: auth required, 401/403/404); cubic P2 email-enumeration (404-vs-403) deferred for a decisiondeleteArtistmust not hard-delete a shared canonical that hassong_artists/catalog dependencies — only remove the caller's linkmain6bdcff7→main) — guard fails closed on query error; 172 tests pass. Review refactors folded in: bespoke existence helper → genericselectSongArtists({ songs?, artists? })(fail-closed policy lives indeleteArtist). Preview-verified live both branches: sole-link + song deps →DELETEreturns 200 and the canonical account +song_artistsrows survive (guard run); sole-link + no deps → still hard-deletes (200 → 404); guardrails 401/400/404/403 (first run)(account, artist)/(org, artist); authorize/execute/disconnect authority modelmain668158d→main) — concept pageconnectors/ownership.mdx+ Concepts nav group; flags (not rewrites) the shipped account-scoped OpenAPI descriptions; join-row-id-vs-pair deliberately undocumented. Verified on localmint dev: page renders, nav wired, callouts/tables/anchors/links resolve, contract reconciles with the live P0b 403 behavior. First of the re-key chain landedUNIQUE(account_id, artist_id)onaccount_artist_ids(+UNIQUE(artist_id, organization_id)onartist_organization_ids) — prerequisite for join-row keyingmainpg_constraint-guarded). Verified via Supabase MCP (before/after): bothUNIQUEconstraints now present with correct defs; recorded as20260708200000 / join_row_unique_constraints(no version drift); 0 duplicate pairs pre/post so dedupe removed nothing; live duplicate insert now rejected with23505(verification). Unblocks the api re-keyownerId→ join row · org-shared union · Composio audit · wipe-and-reconnectmainPATCH /api/artistsmetadata writes off the flat roster edgemainP0 — acute (done, verified in prod)
P0b: connector authority is granted off the flat
account_artist_idsroster edge. ✅ 2026-07-09 (→ api#772, mergedfc1e0d7→main) — Verified live: a non-staff account holding artist A only in its roster (no connection, no org tie) gets 403 fromPOST /api/connectors/actions,POST /api/connectors, andDELETE /api/connectorstargeting A, while its own connectors and roster read/analytics stay functional; staff/self grants preserved (denial run · grant run). Chat-agentgetComposioToolsartist branch covered by the samecheckConnectorAuthoritycut (unit-tested).getComposioTools' artist branch (lib/composio/toolRouter/getComposioTools.ts:48-49) and the connector authorize/execute/disconnect validators (viacheckAccountAccess→checkAccountArtistAccess, which returns true on a bareaccount_artist_idsrow,lib/artists/checkAccountArtistAccess.ts:26-28) all treat roster membership as authority to use the artist's connectors.POST /api/connectors/actionsadditionally accepts an arbitraryaccount_idoverride gated by the same check (lib/composio/connectors/validateExecuteConnectorActionRequest.ts:49-58).getComposioToolsartist branch to require self or org co-membership — stop reading theaccount_artist_idsroster edge — until the full connection-keying lands. This makes any funnel/claim roster link non-authoritative by construction.POST /api/connectors/actionstargeting A; verified live.checkConnectorAuthoritykeeps the workspace-ownership and org-target grant paths fromcheckAccountAccess(they're membership edges, not the roster defect) — reviewer to confirm or tighten to strict self/org-co-member; (2)GET /api/connectors?account_id=still uses the flat check, so a roster-only account can still list (not use) another account's connections for a shared artist — decide whether that metadata leak joins this scope or the P1 re-key.P0a:
POST /api/accounts/artistsis completely unauthenticated. ✅ 2026-07-09 (→ api#771, merged69aa286→main) — Verified live: the endpoint now 401s without a valid credential (previously fully open); an authenticated caller adds only to its own account (idempotent, no unintended write), anemailoverride to a foreign account is 403 and to an unknown email 404 (verification). Follow-up remaining: docs update (add-artist.mdx) documenting auth + 401/403/404.app/api/accounts/artists/route.ts) never callsvalidateAuthContext; the body is only{email, artistId}(lib/accounts/validateAddArtistBody.ts), and the handler resolves the account purely from the suppliedemailthen writesinsertAccountArtistId(accountId, artistId)(lib/accounts/addArtistToAccountHandler.ts). The only middleware is the x402 image-gen paywall. So anyone can link any account to any existing canonical artist — and under today's flat model, that roster row is act-as-artist authority. Exploitable today, no funnel needed.validateAuthContext; derive the account from the authenticated credential (or gate a body override viacheckAccountAccess), never from an unauthenticatedemail.P1 (data-loss guard — done; redesign moved out)
deleteArtistmust not hard-delete a shared canonical. ✅ 2026-07-09 (→ api#770, merged6bdcff7→main) — Verified live: as the sole roster link of a real canonical with 2song_artistsrows,DELETE /api/artists/{id}returned 200 and the canonical account + bothsong_artistsrows survived (pre-guard, that cascade would have destroyed them); the no-dependency case still hard-deletes (guard run). Guard fails closed on query error. Shipped with the genericselectSongArtists({ songs?, artists? })lib per review.lib/artists/deleteArtist.tsremoves only the caller's own link (deleteAccountArtistId(requester, artistId), scoped correctly), but then if zero roster links remain across all accounts it callsdeleteAccountById(artistId)— hard-deleting the canonical account, which cascadessong_artists(account_artist_ids.artist_idisON DELETE CASCADE). A canonical with a catalog/song graph but a single roster link gets destroyed when that one account removes it. Independent of the re-key — can ship any time.song_artists/catalog dependencies (arguably never hard-delete a shared canonical — only drop the caller's link).song_artistsleaves the canonical + its song graph intact; verified.➡️ P1 connection-keying redesign + P2 ops — moved to chat#1865. The
docs(docs#271) anddatabase(database#46, applied to prod) prerequisites shipped as part of this issue. The remainingapire-key (ownerId→ join row, with the join-row-id-vs-(account_id, artist_id)-pair design decision still open), the org-shared connection union, and the P2 Composio audit + wipe-and-reconnect migration are tracked there. Not acute — P0b already blocks cross-account connector use.➡️
PATCH /api/artistsshared-metadata gate — moved to chat#1866 (independent, lower severity;lib/artists/validateUpdateArtistRequest.ts:88→checkAccountArtistAccess).Architecture decisions
account_artist_ids(and org/workspace membership) grants read/analytics only. Act-as-artist authority (connectors, publish, disconnect, destructive artist writes) is separate and connection-scoped. (sweetman, 2026-07-08)ownerId, not by forking the artist.(account, artist); explicitly shareable to(org, artist). Whoever establishes a connection owns it; roster membership never inherits it. (Supersedes the earlier "OAuth completion ⇒ artist manager" idea, which was wrong — an OAuth handshake proves control of the connected social account, not authority over the canonical artist.)account_idoverride on connector endpoints is retained for Recoup-staff support, gated on RECOUP_ORG admin — not on the flat roster edge.Source references
api/lib/composio/toolRouter/getComposioTools.ts(artist branch + owner keying),api/lib/composio/connectors/{authorizeConnector,executeConnectorAction,validateExecuteConnectorActionRequest}.ts,api/lib/artists/checkAccountArtistAccess.ts+api/lib/auth/checkAccountAccess.ts(the flat gate),api/app/api/accounts/artists/route.ts+api/lib/accounts/addArtistToAccountHandler.ts(the unauthenticated write),api/lib/artists/deleteArtist.ts(cascade delete),databasemigration20250129000001_account_artist_ids.sql(schema: bigint PK, no pair uniqueness).