Skip to content

fix(catalogs): catalog songs require authentication and ownership (chat#1912 row 6) - #802

Merged
sweetmantech merged 3 commits into
mainfrom
fix/catalog-songs-require-auth
Jul 30, 2026
Merged

fix(catalogs): catalog songs require authentication and ownership (chat#1912 row 6)#802
sweetmantech merged 3 commits into
mainfrom
fix/catalog-songs-require-auth

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Implements the contract in recoupable/docs#282 — row 6 of chat#1912.

Merge order: docs#282 first. That PR is the product decision (catalogs are account-scoped, not link-readable). If it is rejected or inverted, this PR changes shape with it.

Why

All three catalog-songs operations enforced nothing. Verified on prod 2026-07-30 with no credentials at all:

GET    /api/catalogs/songs?catalog_id=…        →  200  (full tracklist + ISRCs)
POST   /api/catalogs/songs      -d '{}'        →  400  {"missing_fields":["songs"]}
DELETE /api/catalogs/songs      -d '{}'        →  400  {"missing_fields":["songs"]}

The writes returning 400 rather than 401 is the tell: the request reached body validation, so it had already passed the auth layer — there wasn't one. Anyone holding a catalog id could read its tracks, add songs, or delete them. I stopped at the invalid-body probe rather than mutating real data.

Meanwhile GET /api/catalogs/{catalogId}/measurements returns 401 for that same catalog. The catalog report page was built on that asymmetry: it tells a signed-in stranger "the play counts and valuation belong to the account that measured them" on the very page that lists the catalog's songs.

What changed

New lib/songs/authorizeCatalogAccess.ts, gating all three handlers:

  • validateAuthContext401 without credentials
  • selectAccountCatalog ownership check → 403 for a catalog the caller does not own
  • the account is always the authenticated one, never read from the request

Writes can name several catalogs in one body, so every distinct catalog id in songs[] is checked. Authorizing only the first would let one owned catalog carry edits into catalogs the caller does not own — there is a test for exactly that.

Blast radius worth reviewing

This closes a read that is currently open, so any caller relying on unauthenticated GET /api/catalogs/songs will start getting 401. Known consumer: chat's catalog report Manage songs tab, which already sends a Privy bearer, so it is unaffected for the owner. A non-owner viewing a shared catalog URL will now see the songs tab fail — which is the intended consequence of the decision, and the reason row 6 also carries a chat-side copy change (the current cross-account wording promises songs that will no longer render).

Tests

TDD red→green. lib/songs/__tests__/authorizeCatalogAccess.test.ts — 5 cases: no credentials → 401 (and ownership is never queried), unowned catalog → 403, owned → { accountId }, one unowned among several → 403, and ownership always checked against the authenticated account rather than a supplied one.

Full suite 4296 passed / 792 files, lint:check and format:check clean, tsc --noEmit at the repo's existing 236-error baseline with none in the touched files.

Verification

Preview verification against row 6's Works-when (unauthenticated parity between /catalogs/songs and /measurements, plus an owner regression check) will be posted as a PR comment.

🤖 Generated with Claude Code


Summary by cubic

Require authentication and account ownership for GET, POST, and DELETE on /api/catalogs/songs, with a single validator per handler that runs auth before parsing. This closes the open read and aligns with recoupable/docs#282 (chat#1912 row 6).

  • Bug Fixes
    • One validate call per handler: validateCatalogSongsQuery and validateCatalogSongsRequest now take NextRequest, run validateAuthContext first, parse (safeParseJson), validate input, then authorizeCatalogAccess (401 → 400 → 403). They return the validated input plus the authenticated accountId.
    • Ownership checks use the authenticated accountId, verify every catalog id, and read catalogs once via selectAccountCatalogs; DB failures now surface as 500, not a false 403.
    • GET now requires auth (matching /api/catalogs/{id}/measurements), and 500 responses use a generic message.
    • Tests added for validator order, multi-catalog bodies, single-query behavior, and DB failure propagation.

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

Review in cubic

Summary by CodeRabbit

  • Security

    • Added authentication and catalog ownership checks for catalog song operations.
    • Unauthorized catalog access now returns a clear 403 response.
  • Bug Fixes

    • Improved request and query validation for catalog song endpoints.
    • Prevented internal error details from being exposed in server error responses.
    • Added consistent CORS headers to validation and authorization errors.

…at#1912 row 6)

Implements the contract in recoupable/docs#282.

GET, POST and DELETE /api/catalogs/songs enforced nothing. Verified on prod
2026-07-30: all three reached query or body validation with no credentials
(GET 200, POST and DELETE 400 on an empty body), so anyone holding a catalog
id could read its tracks and ISRCs, add songs, or delete them — while the
sibling /api/catalogs/{catalogId}/measurements returned 401 for that same
catalog. The catalog report page relied on that asymmetry, telling a stranger
the valuation belonged to another account on the page that listed its songs.

New authorizeCatalogAccess gates all three on validateAuthContext plus an
account_catalogs ownership check, returning 401 without credentials and 403
for a catalog the caller does not own. The account is always the
authenticated one, never taken from the request.

Writes can name several catalogs in one body, so every distinct catalog is
checked; authorizing only the first would let one owned catalog carry edits
to catalogs the caller does not own.

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

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 30, 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 30, 2026 9:22pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Catalog song handlers now use asynchronous request validation with authentication, catalog ownership authorization, validated account context, and fixed internal error responses.

Changes

Catalog access authorization

Layer / File(s) Summary
Catalog ownership authorization
lib/songs/authorizeCatalogAccess.ts
Adds ownership checks for requested catalogs, returning a CORS-enabled 403 response when any catalog is not owned.
Authenticated request and query validation
lib/songs/validateCatalogSongsRequest.ts, lib/songs/validateCatalogSongsQuery.ts
Validators now authenticate requests, parse inputs asynchronously, authorize catalog access, and return validated data with accountId.
Handler integration and error responses
lib/songs/*CatalogSongsHandler.ts
Create, delete, and get handlers await request-level validation and use fixed "Internal server error" responses for caught failures.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NextRequest
  participant CatalogValidator
  participant validateAuthContext
  participant authorizeCatalogAccess
  participant CatalogSongsHandler
  NextRequest->>CatalogSongsHandler: catalog song request
  CatalogSongsHandler->>CatalogValidator: validate request or query
  CatalogValidator->>validateAuthContext: authenticate request
  validateAuthContext-->>CatalogValidator: accountId
  CatalogValidator->>authorizeCatalogAccess: requested catalog IDs
  authorizeCatalogAccess-->>CatalogValidator: authorization result
  CatalogValidator-->>CatalogSongsHandler: validated data or response
Loading

Poem

Catalog gates now guard the way,
Auth and ownership check each day.
Requests are parsed, errors stay tame,
Account context joins the frame—
Safe song flows can now play.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Solid & Clean Code ✅ Passed New helper matches its file; handlers stay orchestration-only, validation/auth/authorization are split cleanly, and no obvious naming or DRY regressions stand out.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/catalog-songs-require-auth

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.

1 issue found across 5 files

Confidence score: 3/5

  • In lib/songs/createCatalogSongsHandler.ts, bulk authenticated requests can trigger unbounded concurrent ownership checks, which may exhaust the Supabase connection pool and cause catalog-song creation to fail or time out under load—cap request/catalog size or add explicit concurrency limits for the ownership query fan-out.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/songs/createCatalogSongsHandler.ts">

<violation number="1" location="lib/songs/createCatalogSongsHandler.ts:36">
P2: A large authenticated bulk request can fan out into an unbounded number of simultaneous ownership queries, exhausting the Supabase connection pool before the write runs. Bound the request/catalog count or replace the per-catalog Promise.all lookup with a single set-based ownership query.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant Handler as API Handler (GET/POST/DELETE /api/catalogs/songs)
    participant Validate as validateCatalogSongsBodyOrQuery
    participant AuthGate as authorizeCatalogAccess
    participant Auth as validateAuthContext
    participant SelectCat as selectAccountCatalog (Supabase)
    participant DB as Database

    Note over Client,DB: NEW: Authorization gate for all catalog-songs operations

    Client->>Handler: Request (GET/POST/DELETE)
    Handler->>Validate: Validate body or query schema
    alt Invalid body/query
        Validate->>Handler: 400 response
        Handler->>Client: 400
    else Valid
        Validate->>Handler: validated data (includes catalog IDs)
        Handler->>AuthGate: authorizeCatalogAccess(request, catalogIds)
        AuthGate->>Auth: authenticate request (bearer / x-api-key)
        alt No credentials or invalid
            Auth->>AuthGate: 401 NextResponse
            AuthGate->>Handler: 401 response
            Handler->>Client: 401
        else Authenticated
            Auth->>AuthGate: { accountId }
            Note over AuthGate,SelectCat: Check every unique catalog_id in the operation
            loop For each unique catalogId
                AuthGate->>SelectCat: selectAccountCatalog({ accountId, catalogId })
                SelectCat->>DB: Query account_catalogs table
                DB-->>SelectCat: row or null
                SelectCat-->>AuthGate: link or null
            end
            alt Any catalog not owned (null)
                AuthGate->>Handler: 403 response (catalog does not belong to account)
                Handler->>Client: 403
            else All owned
                AuthGate->>Handler: { accountId }
                Note over Handler,DB: Existing handler logic (unchanged)
                alt GET
                    Handler->>DB: selectCatalogSongsWithArtists(...)
                    DB-->>Handler: songs
                    Handler->>Client: 200 + songs
                else POST
                    Handler->>DB: processSongsInput + insert
                    DB-->>Handler: created records
                    Handler->>Client: 200
                else DELETE
                    Handler->>DB: deleteCatalogSongs(...)
                    DB-->>Handler: affected rows
                    Handler->>Client: 200
                end
            end
        end
    end
Loading

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

Re-trigger cubic

Comment thread lib/songs/deleteCatalogSongsHandler.ts Outdated
Comment thread lib/songs/authorizeCatalogAccess.ts Outdated
Comment thread lib/songs/getCatalogSongsHandler.ts Outdated
Comment thread lib/songs/createCatalogSongsHandler.ts Outdated
Comment thread lib/songs/createCatalogSongsHandler.ts Outdated
// row 6). This write previously enforced nothing at all.
const authorized = await authorizeCatalogAccess(
request,
validatedBody.songs.map(song => song.catalog_id).filter((id): id is string => !!id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A large authenticated bulk request can fan out into an unbounded number of simultaneous ownership queries, exhausting the Supabase connection pool before the write runs. Bound the request/catalog count or replace the per-catalog Promise.all lookup with a single set-based ownership query.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/songs/createCatalogSongsHandler.ts, line 36:

<comment>A large authenticated bulk request can fan out into an unbounded number of simultaneous ownership queries, exhausting the Supabase connection pool before the write runs. Bound the request/catalog count or replace the per-catalog Promise.all lookup with a single set-based ownership query.</comment>

<file context>
@@ -28,6 +29,14 @@ export async function createCatalogSongsHandler(request: NextRequest): Promise<N
+    // row 6). This write previously enforced nothing at all.
+    const authorized = await authorizeCatalogAccess(
+      request,
+      validatedBody.songs.map(song => song.catalog_id).filter((id): id is string => !!id),
+    );
+    if (authorized instanceof NextResponse) return authorized;
</file context>

…e query

Four review findings, all valid.

Auth ran after body and query validation, so an unauthenticated malformed
request still got a 400 rather than the promised 401 — the exact confusion
this PR set out to remove, since a 400 without credentials is what proved
there was no auth layer. All three handlers now call validateAuthContext
before touching the request: 401 for no credentials, then 400 for a bad body,
then 403 for someone else's catalog.

authorizeCatalogAccess now takes the already-authenticated accountId rather
than the request, which makes that ordering explicit at each call site, and
reads the caller's catalogs in one query via selectAccountCatalogs instead of
fanning out one query per catalog named in the body. That also fixes a false
403: selectAccountCatalog returns null on a query failure, so a database
outage was being reported as "does not belong" and would not be retried.
selectAccountCatalogs throws, so an outage now surfaces as a 500.

The 500 body no longer echoes the exception text, which can now carry auth
and database failure detail. The detail is still logged.

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

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-07-30

Preview https://api-c3tzvslpp-recoup.vercel.app, built from head ae380c09 (deployment resolved by sha). API key minted on the preview via POST /api/agents/signup, since prod keys 401 against a preview.

Fixtures: catalog A = 41aa8ad9-a347-438f-b2b4-998f49f8d4ec (a real customer's catalog, owned by account 148e1644), and a fresh preview account d02ea9f4 that owns nothing.

Results

# Check Before (prod today) Actual on preview
1 GET catalog A, no credentials 200 with the full tracklist and ISRCs 401 Exactly one of x-api-key or Authorization must be provided
2 POST, no credentials, invalid body 400 missing_fields: ["songs"] 401
3 DELETE, no credentials, invalid body 400 missing_fields: ["songs"] 401
4 GET with an invalid api key 401 Unauthorized
5 GET catalog A with a valid key from another account 200 403 This catalog does not belong to the authenticated account
6 DELETE a song from catalog A, valid key, not the owner would have deleted it 403
7 Owner read — create a catalog, read its songs 200 {"songs":[],"pagination":{...}}
8 Owner write — add a song to own catalog 200, song added with artist resolved
9 Authenticated, invalid body 400 missing_fields: ["songs"]

Rows 2, 3 and 9 together are the ordering proof: with no credentials a malformed body now returns 401, and only once authenticated does the same body return 400. That is 401 → 400 → 403, which is what the review asked for.

Row 6 was checked against the database, not just the response. After the cross-account DELETE attempt on catalog A, SELECT count(*) FROM catalog_songs WHERE catalog = '41aa8ad9-…' still returns 24. The rejection actually protected the data rather than merely returning a 403.

Review findings — all four valid, all fixed

Finding Verdict Fix
cubic P2 ×2 — auth ran after body/query validation, so unauthenticated malformed requests still got 400 Valid, and embarrassing this PR's own argument was that a 400 without credentials is what proves there is no auth layer, and the first implementation reproduced exactly that. All three handlers now call validateAuthContext before parsing or validating. Rows 2, 3 and 9 above verify it live
cubic P2 — a database failure surfaced as 403 "does not belong" Valid selectAccountCatalog returns null on a query error, so an outage was indistinguishable from a real denial and clients would not retry. Switched to selectAccountCatalogs, which throws, so an outage surfaces as a 500
cubic P2 — unbounded Promise.all fan-out on a bulk body Valid ownership is now one query for the caller's catalogs, checked in memory. A body naming 500 catalogs makes one query, not 500. Test asserts selectAccountCatalogs is called exactly once
cubic P2 — 500 responses echoed raw exception text Valid the catch now returns a fixed "Internal server error"; the detail is still console.error'd. This path can now carry auth and database failure text, so it matters more than it did before

authorizeCatalogAccess also changed shape as a result: it takes the already-authenticated accountId rather than the request, which makes the ordering explicit at every call site instead of hiding it inside the helper.

Blast radius, restated after verification

Row 1 is a behaviour change on a currently-open read. Anything calling GET /api/catalogs/songs without credentials starts getting 401 the moment this merges. Chat's Manage songs tab sends a Privy bearer, so owners are unaffected — verified as rows 7 and 8. A non-owner opening a shared catalog URL will now see that tab fail, which is the intended consequence of the decision ratified in docs#282, and is why chat#1917 rewords the copy that currently promises them those songs.

Suggested merge order from here: this → chat#1917, so the copy stops promising the songs tab as soon as it starts refusing.

Full suite 4296 passed / 792 files, lint:check and format:check clean, tsc --noEmit at the repo's existing baseline with none in the touched files.

Test residue

The verification created one preview account (d02ea9f4), one empty catalog (81704866-…) and added a single song to it. No customer data was modified — confirmed by the row-6 count check above.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 5 files (changes from recent commits).

Confidence score: 4/5

  • In lib/songs/createCatalogSongsHandler.ts, the 113-line handler exceeds the maintainability guideline, which raises the chance of future regressions because validation, orchestration, and response logic are harder to reason about in one place—extract focused helper functions (or service-layer calls) to keep the route handler lean.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/songs/createCatalogSongsHandler.ts">

<violation number="1" location="lib/songs/createCatalogSongsHandler.ts:9">
P2: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

This handler file is 113 lines, exceeding the 100-line limit set by our maintainability guidelines. Keeping route handlers lean makes them easier to scan and test. Consider moving the data transformation, bulk insertion, and result filtering steps into one or more dedicated helpers so the handler stays focused on orchestration and auth.</violation>
</file>

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

Re-trigger cubic

import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { insertCatalogSongs } from "@/lib/supabase/catalog_songs/insertCatalogSongs";
import { selectCatalogSongsWithArtists } from "@/lib/supabase/catalog_songs/selectCatalogSongsWithArtists";
import { processSongsInput } from "@/lib/songs/processSongsInput";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Custom agent: Enforce Clear Code Style and Maintainability Practices

This handler file is 113 lines, exceeding the 100-line limit set by our maintainability guidelines. Keeping route handlers lean makes them easier to scan and test. Consider moving the data transformation, bulk insertion, and result filtering steps into one or more dedicated helpers so the handler stays focused on orchestration and auth.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/songs/createCatalogSongsHandler.ts, line 9:

<comment>This handler file is 113 lines, exceeding the 100-line limit set by our maintainability guidelines. Keeping route handlers lean makes them easier to scan and test. Consider moving the data transformation, bulk insertion, and result filtering steps into one or more dedicated helpers so the handler stays focused on orchestration and auth.</comment>

<file context>
@@ -6,6 +6,7 @@ import { processSongsInput } from "@/lib/songs/processSongsInput";
 import { SongInput } from "@/lib/songs/formatSongsInput";
 import { validateCatalogSongsRequest } from "@/lib/songs/validateCatalogSongsRequest";
 import { authorizeCatalogAccess } from "@/lib/songs/authorizeCatalogAccess";
+import { validateAuthContext } from "@/lib/auth/validateAuthContext";
 
 /**
</file context>

Comment thread lib/songs/createCatalogSongsHandler.ts Outdated
Comment on lines +25 to +44
@@ -28,6 +35,14 @@
return validatedBody;
}

// Every catalog named in the body must belong to the caller. This write
// previously enforced nothing at all.
const forbidden = await authorizeCatalogAccess(
auth.accountId,
validatedBody.songs.map(song => song.catalog_id).filter((id): id is string => !!id),
);
if (forbidden) return forbidden;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP / OCP

  • actual: 2 distinct auth / validation calls in the handler function.
  • required: move both validateAuthContext and authorizeCatalogAccess calls to a standalone validate function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a8098fab. Both calls moved into validateCatalogSongsRequest, which POST and DELETE already shared, so there is one standalone validate function rather than a new one per verb.

It now takes the request and runs credentials → body shape → ownership, returning either the 401/400/403 response or the validated body plus the authenticated accountId. Body parsing moved in with it, via the shared safeParseJson.

The handler is back to one validation call followed by business logic:

const validatedBody = await validateCatalogSongsRequest(request);
if (validatedBody instanceof NextResponse) return validatedBody;

Keeping the order inside the validator also makes it testable as a unit, which it now is: 401 before 400, ownership never consulted for an invalid body, and every catalog named in the body checked rather than only the first.

Comment thread lib/songs/deleteCatalogSongsHandler.ts Outdated
Comment on lines +23 to +41
@@ -26,6 +33,14 @@ export async function deleteCatalogSongsHandler(request: NextRequest): Promise<N
return validatedBody;
}

// Every catalog named in the body must belong to the caller. This write
// previously enforced nothing at all.
const forbidden = await authorizeCatalogAccess(
auth.accountId,
validatedBody.songs.map(song => song.catalog_id).filter((id): id is string => !!id),
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP / OCP

  • actual: 2 distinct auth / validation calls in the handler function.
  • required: move both validateAuthContext and authorizeCatalogAccess calls to a standalone validate function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a8098fab. Both calls moved into validateCatalogSongsRequest, which POST and DELETE already shared, so there is one standalone validate function rather than a new one per verb.

It now takes the request and runs credentials → body shape → ownership, returning either the 401/400/403 response or the validated body plus the authenticated accountId. Body parsing moved in with it, via the shared safeParseJson.

The handler is back to one validation call followed by business logic:

const validatedBody = await validateCatalogSongsRequest(request);
if (validatedBody instanceof NextResponse) return validatedBody;

Keeping the order inside the validator also makes it testable as a unit, which it now is: 401 before 400, ownership never consulted for an invalid body, and every catalog named in the body checked rather than only the first.

Comment thread lib/songs/getCatalogSongsHandler.ts Outdated
Comment on lines +22 to +38
// Authenticate before touching the request, so a caller with no
// credentials gets 401 rather than a query-validation 400 (chat#1912 row 6).
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) return auth;

const { searchParams } = new URL(request.url);

const validatedQuery = validateCatalogSongsQuery(searchParams);
if (validatedQuery instanceof NextResponse) {
return validatedQuery;
}

// Catalog songs are account-scoped (contract in recoupable/docs#282): this
// read was previously open to anyone holding a catalog id, while the
// sibling /measurements endpoint required credentials.
const forbidden = await authorizeCatalogAccess(auth.accountId, [validatedQuery.catalog_id]);
if (forbidden) return forbidden;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP / OCP

  • actual: 2 distinct auth / validation calls in the handler function.
  • required: move both validateAuthContext and authorizeCatalogAccess calls to validateCatalogSongsQuery.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a8098fab. Both moved into validateCatalogSongsQuery as asked.

It takes the request rather than URLSearchParams and runs credentials → query shape → ownership, returning either the 401/400/403 response or the validated query plus the authenticated accountId. The handler is one call again:

const validatedQuery = await validateCatalogSongsQuery(request);
if (validatedQuery instanceof NextResponse) return validatedQuery;

Signature change is contained: this validator had no callers outside the GET handler.

…view)

Review (@sweetmantech): each handler made two distinct auth/validation calls.
Both now live in the validate function the handler already used.

validateCatalogSongsQuery takes the request and does credentials, then query
shape, then ownership. validateCatalogSongsRequest does the same for the body
and is shared by POST and DELETE. Each returns the validated input plus the
authenticated accountId, or the 401/400/403 response.

Handlers are back to a single validation call followed by business logic, and
body parsing moved into the validator via the shared safeParseJson.

Adds validateCatalogSongsRequest tests covering the order that is the actual
contract: 401 before 400 before 403, ownership never consulted for an invalid
body, and every catalog in the body checked rather than only the first.

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

Copy link
Copy Markdown
Contributor Author

Re-verified after the refactor — 2026-07-30

Preview https://api-hh8sa62lq-recoup.vercel.app, built from head a8098fab (resolved by sha). The refactor moved the 401/400/403 ordering out of the handlers and into the validators, so the whole probe set was re-run rather than assumed to still hold.

# Check Actual
1 GET, no credentials 401
2 POST, no credentials, invalid body 401
3 DELETE, no credentials, invalid body 401
4 GET, invalid api key 401
5 POST, no credentials, malformed JSON 401
6 GET another account's catalog, valid key 403
7 DELETE from another account's catalog, valid key 403
8 Authenticated, invalid body 400
9 Owner reads own catalog 200
10 Owner writes to own catalog 200
11 Customer data intact after the cross-account delete attempt catalog_songs for 41aa8ad9-… still 24

Row 5 is new and worth calling out: malformed JSON with no credentials is now 401. Previously the body was parsed first, so request.json() threw and the catch turned it into a 500 — a review finding that the reordering fixes as a side effect. Rows 2, 3, 5 and 8 together are the ordering proof: 401 → 400 → 403.

Row 11 was checked in the database rather than inferred from the 403.

What changed since the last run

Handlers each made two auth/validation calls; both now live in the validate function the handler already used, per review. validateCatalogSongsQuery and validateCatalogSongsRequest take the request and run credentials → shape → ownership, returning the validated input plus the authenticated accountId. Body parsing moved in with them, through the shared safeParseJson — which is what makes row 5 a 401 instead of a 500.

Added validateCatalogSongsRequest unit tests for the order, since the contract now lives there: 401 before 400, ownership never consulted for an invalid body, and every catalog in the body checked rather than only the first.

Full suite 4301 passed / 793 files, lint:check, format:check and tsc clean.

Test residue: one preview account and one empty catalog with a single song. No customer data modified, per row 11.

@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: 1

🧹 Nitpick comments (4)
lib/songs/createCatalogSongsHandler.ts (2)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract a shared sanitized-error-response helper. All three handlers now duplicate the same catch-all NextResponse.json({ status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }) block — one root cause (no shared helper) repeated three times.

  • lib/songs/createCatalogSongsHandler.ts#L80-94: replace this catch block's response construction with a shared internalErrorResponse() helper call.
  • lib/songs/deleteCatalogSongsHandler.ts#L53-67: replace this catch block's response construction with the same shared helper.
  • lib/songs/getCatalogSongsHandler.ts#L52-66: replace this catch block's response construction with the same shared helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/songs/createCatalogSongsHandler.ts` at line 1, Extract a shared
internalErrorResponse() helper that returns the existing sanitized 500
NextResponse with getCorsHeaders(), then replace the duplicated catch-block
response construction in the handlers’ catch paths with calls to this helper.

80-94: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sanitized 500 response duplicated across handlers.

The catch-all "Internal server error" response body/status/headers block is now identical in createCatalogSongsHandler.ts, deleteCatalogSongsHandler.ts, and getCatalogSongsHandler.ts. Extracting a shared helper (e.g. internalErrorResponse()) would keep the sanitization fix in one place going forward.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/songs/createCatalogSongsHandler.ts` around lines 80 - 94, Extract the
duplicated sanitized 500 response into a shared internalErrorResponse helper and
update createCatalogSongsHandler, deleteCatalogSongsHandler, and
getCatalogSongsHandler to return it from their catch blocks. Preserve the
existing generic error body, 500 status, and CORS headers, while keeping
detailed errors limited to server-side logging.
lib/songs/deleteCatalogSongsHandler.ts (1)

53-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sanitized 500 response duplicated (see createCatalogSongsHandler.ts).

Same catch-all error block as the other two handlers — candidate for a shared helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/songs/deleteCatalogSongsHandler.ts` around lines 53 - 67, Extract the
duplicated sanitized 500 response from the catch block in the catalog song
handlers into a shared helper, then reuse that helper from
deleteCatalogSongsHandler and the corresponding create handler. Preserve the
existing internal error logging, generic client-facing message, 500 status, and
CORS headers.
lib/songs/getCatalogSongsHandler.ts (1)

52-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sanitized 500 response duplicated (see createCatalogSongsHandler.ts).

Same catch-all error block as the other two handlers — candidate for a shared helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/songs/getCatalogSongsHandler.ts` around lines 52 - 66, Extract the
duplicated sanitized 500 response construction from the catch block in the
catalog songs handler into a shared helper, reusing the existing equivalent
helper or adding one alongside createCatalogSongsHandler. Update this handler
and the other matching handlers to call it while preserving the Internal server
error message, 500 status, and CORS headers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/songs/validateCatalogSongsRequest.ts`:
- Around line 23-46: Rename the validator file to validateCatalogSongsBody.ts
and rename the exported function validateCatalogSongsRequest to
validateCatalogSongsBody. Update both call sites and all imports/references to
use the new filename and symbol, preserving the existing validation behavior and
API.

---

Nitpick comments:
In `@lib/songs/createCatalogSongsHandler.ts`:
- Line 1: Extract a shared internalErrorResponse() helper that returns the
existing sanitized 500 NextResponse with getCorsHeaders(), then replace the
duplicated catch-block response construction in the handlers’ catch paths with
calls to this helper.
- Around line 80-94: Extract the duplicated sanitized 500 response into a shared
internalErrorResponse helper and update createCatalogSongsHandler,
deleteCatalogSongsHandler, and getCatalogSongsHandler to return it from their
catch blocks. Preserve the existing generic error body, 500 status, and CORS
headers, while keeping detailed errors limited to server-side logging.

In `@lib/songs/deleteCatalogSongsHandler.ts`:
- Around line 53-67: Extract the duplicated sanitized 500 response from the
catch block in the catalog song handlers into a shared helper, then reuse that
helper from deleteCatalogSongsHandler and the corresponding create handler.
Preserve the existing internal error logging, generic client-facing message, 500
status, and CORS headers.

In `@lib/songs/getCatalogSongsHandler.ts`:
- Around line 52-66: Extract the duplicated sanitized 500 response construction
from the catch block in the catalog songs handler into a shared helper, reusing
the existing equivalent helper or adding one alongside
createCatalogSongsHandler. Update this handler and the other matching handlers
to call it while preserving the Internal server error message, 500 status, and
CORS headers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 207b2781-1456-457b-826d-17479dfc7657

📥 Commits

Reviewing files that changed from the base of the PR and between 9e31944 and a8098fa.

⛔ Files ignored due to path filters (2)
  • lib/songs/__tests__/authorizeCatalogAccess.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/songs/__tests__/validateCatalogSongsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (6)
  • lib/songs/authorizeCatalogAccess.ts
  • lib/songs/createCatalogSongsHandler.ts
  • lib/songs/deleteCatalogSongsHandler.ts
  • lib/songs/getCatalogSongsHandler.ts
  • lib/songs/validateCatalogSongsQuery.ts
  • lib/songs/validateCatalogSongsRequest.ts

Comment on lines +23 to +46
export type ValidatedCatalogSongsRequest = CatalogSongsRequest & { accountId: string };

/**
* Validates a catalog songs request body.
* Validates a catalog songs write (POST and DELETE share it): credentials,
* then body shape, then that every catalog named belongs to the caller.
*
* The order is the contract (chat#1912 row 6, recoupable/docs#282). Auth runs
* before the body is read so a caller with no credentials gets 401 rather than
* a validation 400 — a 400 without credentials is precisely what proved this
* endpoint had no auth layer at all. A write can name several catalogs, and
* every one is checked: authorizing only the first would let one owned catalog
* carry edits into catalogs the caller does not own.
*
* @param body - The request body to validate.
* @returns A NextResponse with an error if validation fails, or the validated body if validation passes.
* @param request - The incoming request, carrying `x-api-key` or a bearer token
* @returns A NextResponse (401/400/403), or the validated body plus the
* authenticated account
*/
export function validateCatalogSongsRequest(body: unknown): NextResponse | CatalogSongsRequest {
export async function validateCatalogSongsRequest(
request: NextRequest,
): Promise<NextResponse | ValidatedCatalogSongsRequest> {
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) return auth;

const body = await safeParseJson(request);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

File/function naming violates validate*.ts convention.

This file validates the request body but is named validateCatalogSongsRequest.ts / exports validateCatalogSongsRequest, not validateCatalogSongsBody.ts / validateCatalogSongsBody as the path instruction for lib/**/validate*.ts requires.

As per path instructions, lib/**/validate*.ts files must "Follow naming: validateBody.ts or validateQuery.ts".

♻️ Suggested rename
-export type ValidatedCatalogSongsRequest = CatalogSongsRequest & { accountId: string };
+export type ValidatedCatalogSongsBody = CatalogSongsRequest & { accountId: string };

-export async function validateCatalogSongsRequest(
+export async function validateCatalogSongsBody(
   request: NextRequest,
-): Promise<NextResponse | ValidatedCatalogSongsRequest> {
+): Promise<NextResponse | ValidatedCatalogSongsBody> {

Also rename the file to validateCatalogSongsBody.ts and update its two call sites.

#!/bin/bash
# Find all call sites/imports of validateCatalogSongsRequest to confirm rename scope
rg -nP '\bvalidateCatalogSongsRequest\b' --type=ts
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/songs/validateCatalogSongsRequest.ts` around lines 23 - 46, Rename the
validator file to validateCatalogSongsBody.ts and rename the exported function
validateCatalogSongsRequest to validateCatalogSongsBody. Update both call sites
and all imports/references to use the new filename and symbol, preserving the
existing validation behavior and API.

Source: Path instructions

@sweetmantech
sweetmantech merged commit 583a8e8 into main Jul 30, 2026
6 checks passed
sweetmantech added a commit to recoupable/chat that referenced this pull request Jul 30, 2026
…1912 row 6) (#1917)

* fix(catalog): cross-account copy stops promising the songs tab (chat#1912 row 6)

Third link of the row 6 chain, after recoupable/docs#282 (contract) and
recoupable/api#802 (enforcement).

The other-account state said "The songs are listed under Manage songs, but
the play counts and valuation belong to the account that measured them."
Once catalog songs are account-scoped, that tab returns 403 for a non-owner,
so the copy would be pointing them at something that now fails.

Rewords to state what is true either way and offers an in-app next step
instead. The existing guards still hold: no em dashes, and no CTA off to
recoupable.dev.

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

* feat(catalog): give the cross-account state a way forward, not just text

Review (@sweetmantech): the state should offer a happy path rather than a
paragraph and a dead end.

It already had a button, but "Go to your catalogs" is only a happy path for
someone who has catalogs. A stranger following a shared catalog link usually
has none, so that button landed them on an empty page — a second dead end.

The action now adapts: viewers with catalogs still go to theirs, and viewers
with none get "Value your catalog" pointing at /setup/artists, the step that
creates one. Body copy leads into the action instead of explaining the
refusal twice.

useOwnsCatalog already loads the viewer's catalog list for the ownership
check, so the flag costs no extra request.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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