Skip to content

Close the valuation → onboarding loop: cross-subdomain Privy auth + materialize valuation as catalog + authenticated /catalogs handoff #1801

Description

@sweetmantech

Tracking issue for closing the loop after a marketing /valuation run — turn a warm lead into an authenticated Recoup session looking at their own catalog, instead of dumping them on a generic chat homepage where the work feels lost. Follows chat#1798 (the valuation gate + lead capture, shipped to prod 2026-06-17). Status 2026-06-18: Phase 1 (cross-subdomain auth) and Phase 2 (catalog materialization — POST /api/catalogs, api#677 + docs#243) are live on prod — see Done. Phase 3 — the handoff works end-to-end on prod (2026-06-18): a Claire Rosinkranz valuation → "Get the full report" → authenticated chat.recoupable.com/catalogs/{id} showing "Found 52 songs in catalog" — auth, claim, materialize, and the count are all live (marketing#29 + api#677 + api#681). Root cause fixed: the capture path now enriches songs at capture (api#684, merged testmain) — song_artists linked synchronously (preview-verified), notes queued; once the note queue drains, captured songs pass isCompleteSong and the per-track list renders (not just the count). Remaining: chat#1803 landing polish (play counts + valuation band). (Out-of-scope business context: the downstream goal is converting the warm lead to a paid Recoup account; this issue only builds the authenticated, pre-populated handoff that makes that possible.)

Decision 2026-06-18 (sweetman, in docs#243 review): the create-catalog request takes a root snapshot field, not the originally-planned from: { snapshot_id } wrapper (YAGNI — one materialization source today; flat matches the sibling POST /api/catalogs/songs). The merged contract reflects this. Re-anchor:api#677 adopted the flat field (live on prod); marketing's claimCatalog.ts (shipped in #28 still sending from:{snapshot_id}) has not — verified broken on prod 2026-06-18, tracked under Open — follow-ups.

Goal

When a signed-in lead finishes a valuation on recoupable.com/valuation and clicks "Get the full report," they land on chat.recoupable.com/catalogs/{id} already authenticated, viewing the catalog they just measured (tracks + play counts + the valuation band) — no second sign-in, no empty state. Concretely this requires three things, the first of which is now done:

  1. Cross-subdomain auth so the marketing Privy session is recognized in chat.Done (Phase 1, 2026-06-18) — one *.recoupable.com HttpOnly cookie session.
  2. Catalog materialization so the valuation's measured tracks become the account's catalog (today the run writes playcount_snapshots + global song_measurements but no account_catalogs/catalog_songs, so GET /api/accounts/{id}/catalogs is empty after sign-in).
  3. An authenticated deep-link handoff to that catalog, with a landing view that actually shows the work + a next action.

Status (updated 2026-06-18)

Point PR(s) State
Phase 1a — Privy v1→v3 chat#1802chat#1804 Done — live on prod (react-auth:3.31.0)
Phase 1b — Privy cookies + DNS config, no PR Done — cross-domain verified on prod
Phase 2 — contract (docs) docs#243 Done — merged to main (2026-06-18)
Phase 2 — impl (api) api#677 Donetestmain, live on prod (materialize verified songs_added: 23)
Phase 3 — marketing CTA marketing#28marketing#29 Done — CTA live; the from-shape bug fixed by #29 (merged) → materializes in prod
Follow-up — song_artists read-path gap (LEFT-join) api#681 Done — merged testmain, live on prod (0→58/23)
Follow-up — capture writes missing song info (artists + notes) api#684 Done — merged testmain, live on prod; capture links artists (preview-verified) + queues notes
Loop end-to-end ✅ Materialize + count + artist-link live on prod; per-track list renders as the async note queue drains (isCompleteSong needs notes too)
Phase 3 — chat landing chat#1803 🔵 In review (partial — play counts + band deferred)

Remaining: chat#1803 landing polish (play counts + valuation band). Captured songs now get artists at capture (api#684); their notes fill in asynchronously via the existing queue/worker, after which they pass isCompleteSong and render in the list.

Done

  • chat: upgrade @privy-io/react-auth v1.88.4 → v3.31.0 (chat#1802).
    ✅ Shipped 2026-06-18 (squash → test via chore: upgrade @privy-io/react-auth v1.88.4 → v3.31.0 #1802, then testmain via chat#1804; prod redeployed).
    Bumped to ^3.31.0 (matches marketing). Audited all 48 @privy-io call sites — chat uses only usePrivy + the PrivyProvider component; none of the v3-renamed/removed hooks (useLoginToFrame, useSignAuthorization, useSolanaWallets, useWallets, login/logout callbacks) and no Solana (the documented I complete mobile ui. #1 v3 failure point), so usePrivy()/user.email/user.wallet were unchanged. Only code change: embeddedWallets.createOnLoginembeddedWallets.ethereum.createOnLogin.
    Verified on prod: chat.recoupable.com now sends privy-client: react-auth:3.31.0 (read off the live auth.privy.io/api/v1/apps/… request header); Vercel preview build green; 68 unit tests pass; full login → authenticated session confirmed (see Phase 1b).

  • Enable Privy HttpOnly cookie sessions on the prod app (Privy dashboard + DNS — not a PR).
    ✅ Done 2026-06-18 (config only, no code). On the prod Privy app (id cm4t193…): enabled HttpOnly cookies + SameSite=Lax, registered apex recoupable.com, and verified the domain via the Privy-provided DNS records — a CNAME privy.recoupable.com → cm4t193….api.privy.systems plus a TXT challenge (the initial CNAME-only state read Pending; adding the TXT flipped it to Verified).
    Verified end-to-end on the real prod domains 2026-06-18: signed in on www.recoupable.com (email sweetmantech@gmail.com) → navigated to chat.recoupable.com → landed authenticated, no re-login (authenticated workspace + roster rendered; no sign-in gate, no login modal). Network proof: the fresh login set privy-token with Domain=recoupable.com; HttpOnly; Secure; SameSite=Lax (it disappeared from document.cookie, confirming HttpOnly); that cookie was then sent from chat.recoupable.com to POST privy.recoupable.com/api/v1/sessions, which returned the user (linked_accounts[].address = sweetmantech@gmail.com). The privy.recoupable.com CNAME is the session-validation host.
    Notes: one-time — switching storage mode invalidated existing localStorage sessions, so the first login after rollout re-authenticates (observed: the stale pre-verification session did not carry; a fresh login did). *.vercel.app previews don't share the cookie (expected). Both apps stay on the Bearer-header approach (getAccessToken()Authorization); recoup-api still reads no cookies. No secret/token value recorded here.

  • docs: POST /api/catalogs contract (docs#243).
    ✅ Shipped 2026-06-18 (merged to main, commit e15ffc7). Documentation-driven: this contract is the spec the api impl must match.
    Added the OpenAPI path + CreateCatalogRequest/CreateCatalogResponse schemas to releases.json and a frontmatter-only reference page. Request shape (per the decision callout above): { name?, snapshot? } — at least one required; root snapshot (uuid), no from wrapper. Also refactored the nav (the Releases tab → Catalog; split Songs & Catalogs into Songs then Catalogs) and added a 2-way cross-link between the snapshot field and Create measurement job.
    Verified: JSON re-validated as parseable on each change; all docs#243 review threads resolved (description trimmed to "Create a catalog.", frontmatter-only mdx, flat snapshot, 2-way DRY link).

  • api: POST /api/catalogs — create + materialize a valuation snapshot into a catalog (api#677).
    ✅ Shipped 2026-06-18 — squash → test (f24d4c7), then testmain (release Sweetmantech/myc 2082 cc param #678); live on prod (test re-synced with main). Implements the docs#243 contract.
    Account from the bearer (no IDOR); root snapshot materializes catalogs + account_catalogs + catalog_songs, idempotent via playcount_snapshots.catalog; no DB migration. Two bugs caught during live verification and fixed: (1) re-anchored from:{snapshot_id} → flat snapshot; (2) sourced the materialized ISRCs from song_measurements (by snapshot) instead of the snapshot's isrcs column — which is null for album-scoped valuation runs, so without this the catalog would link empty. TDD throughout (lib/catalog 36 tests); uses the shared successResponse.
    Verified live: name-only create → 200; materialize → 200 songs_added: 23 (minted a real snapshot → preview captured 23 tracks → claim created 23 catalog_songs); idempotent re-claim → same catalog, songs_added: 0; 400 (flat snapshot; old from rejected) / 401 / 404 all correct. (verification · re-test)
    ⚠️ Caveat: the GET /api/catalogs/songs returns the measured tracks half of the Done-when is not yet met — claim creates the catalog_songs, but the artist-inner-join read endpoints hide artist-less tracks (the song_artists follow-up below).

  • marketing: claim-on-click + deep-link the "Get the full report" CTA (marketing#28 + marketing#29).
    ✅ Shipped 2026-06-18 — Tech322/get fan #28 added the CTA (claim + deep-link to chat.recoupable.com/catalogs/{id}); Tech322/meetings #29 fixed the request shape (from:{snapshot_id} → flat snapshot) after it was verified broken on prod (the old shape created empty catalogs). Both merged to main, live.
    Verified on the Tech322/meetings #29 preview (real browser flow): Clairo valuation → "Get the full report" → claim body {"name":"Clairo Catalog","snapshot":"9bfda665…"} → 200 → materialized 58 catalog songs (confirmed via the api#681 preview: total_count: 58); redirect lands authenticated on /catalogs/{id}. (verification)
    ✅ Now resolved: with api#681 live (below), the materialized tracks render — verified end-to-end on prod (52 songs).

  • api: LEFT-join artists in the catalog-songs read so materialized tracks surface (api#681).
    ✅ Shipped 2026-06-18 — merged testmain, live on prod (test re-synced with main). Option (b): dropped song_artists!inner/accounts!inner in selectCatalogSongsWithArtists (kept songs!inner); valuation-captured tracks with no song_artists now return with artists: [].
    Verified: same catalog 0 (prod inner-join) → 58 (Clairo) / 23 (Bad Bunny) on the preview LEFT-join; pagination + row shape intact; empty/missing catalogs still a clean 0. Cross-caller check: all 8 consumers of the shared lib type-safe (no artists[0] assumptions), 161 tests pass; only behavior change is measurement-scope resolvers now include artist-less songs (a latent-bug fix). (verification · cross-caller) Longer-term (option a): capture writes song_artists for real artist names.

  • api: enrich valuation-captured songs at capture (the root cause — option a) (api#684).
    ✅ Shipped 2026-06-18 — merged testmain, live on prod. The capture path (mapUnmappedAlbumTracks) discarded the Spotify track.artists and skipped the manual flow's enrichment, so captured songs had no song_artists/notes and the chat catalog view's isCompleteSong filtered them out (count shown, list empty). Now it carries track.artists through and runs the same enrichment: linkSongsToArtists (auto-creates the artist account) + queueRedisSongs (queues note generation). TDD red→green; lib/research/playcounts + lib/songs 109 pass; tsc/format/lint clean.
    Preview-verified (real captures): fresh unmapped artists Anjimile + Florry → captured songs come back with artists: ["Anjimile"] / ["Florry"] (auto-created accounts), while an old-capture Clairo ISRC still returns 0 on the same endpoint. (verification · re-test)
    Note: song_artists is written synchronously at capture (verified); notes arrive asynchronously as the Redis queue/worker drains, after which songs pass isCompleteSong and render in the list.

In review — Phase 3: the handoff + landing (chat)

  • chat: make /catalogs/[id] a real landing — play counts + valuation + next action. (partial)
    • → PR (open, base test): chat#1803 — landing header (catalog name + real track count + single next action that opens the agent pre-asked about the catalog) + real onboarding empty state. ✅ preview build green, tsc/eslint clean.
    • ⚠️ Deferred — needs new data plumbing (did not fake): per-track play counts and the valuation band are not surfaced yet. No endpoint returns play counts by ISRC/catalog (only ?spotify_album_id= per-album and per-track /research/tracks/{id}/measurements), and no persisted valuation band exists. Proposed follow-up: a new GET /api/catalogs/measurements?catalogId= (catalog → snapshot → song_measurements, latest per ISRC + a derived band), then surface both on the landing.
    • Why: today the catalog song rows show metadata only (title/artist/album/ISRC, no play counts), the empty state is a bare "No catalogs found.", and there's no onboarding nudge — so even a populated catalog doesn't visibly reflect the valuation the lead just ran. ([source: chat/components/Catalog/CatalogSongsPageContent.tsx], [components/VercelChat/tools/catalog/CatalogSongRow.tsx])
    • Done when: a warm lead landing on /catalogs/{id} sees their tracks with play counts, their valuation band, and a guided next step — continuity with what they did on marketing is obvious.

Merge sequencing

  1. chat Privy SDK upgrade (Phase 1a) — ✅ done (chore: upgrade @privy-io/react-auth v1.88.4 → v3.31.0 #1802Test #1804, live on prod).
  2. Enable Privy cookies (Phase 1b, config) — ✅ done; cross-domain verified on prod.
  3. Phase 2 contract (docs#243) — ✅ done (merged to main).
  4. Phase 2 api impl (api#677) — ✅ done; merged testmain, live on prod.
  5. marketing claimCatalog flatten (from:{snapshot_id}snapshot) — ✅ done (marketing#29, live on prod).
  6. song_artists read-path fix (api#681) — ✅ done; merged testmain, live on prod. (Handoff + count verified end-to-end on prod 2026-06-18.)
  7. api — capture enrichment (artists + notes, api#684) — ✅ done; merged testmain, live on prod. Artists link at capture (preview-verified); notes fill in async via the queue.
  8. chat#1803 landing — the remaining polish (per-track play counts + valuation band).

Architecture decisions

  • Cross-domain auth = Privy HttpOnly cookie-sharing; keep the marketing gate. Decided 2026-06-17 (sweetman); verified working on prod 2026-06-18. Same prod Privy app on both surfaces + HttpOnly cookie on apex recoupable.com (Domain=recoupable.com; HttpOnly; Secure; SameSite=Lax) → the chat redirect lands authenticated. Chosen over relocating sign-in to chat's domain (the earlier instinct), which would lose the on-marketing valuation reveal and the early email/lead capture that feeds the Attio pipeline from Gate /valuation results behind Privy sign-in + capture valuation leads (email + artist) #1798.
  • recoup-api needs no change for cookies. Auth is two header-based mechanisms — Privy Bearer JWT + x-api-key — verified token-agnostically; it never reads cookies. Staying on the Bearer-header approach (not cookie auto-send) avoids touching CORS (* + no Allow-Credentials). ([source: api/lib/auth/validateAuthContext.ts], [api/lib/networking/getCorsHeaders.ts])
  • Existing API keys are unaffected. The x-api-key path (hashed in account_api_keys) is fully independent of Privy/cookies — CLI, agents, RECOUP_API_KEY, and Trigger.dev tasks keep working unchanged.
  • Create-catalog request is flat: { name?, snapshot? }. The materialization source is a root snapshot (uuid), not a from: {…} wrapper — YAGNI (one source today) and consistent with the flat POST /api/catalogs/songs. Supersedes the earlier from: { snapshot_id } plan (see the dated decision callout near the top); re-anchors api#677 + marketing's claimCatalog.
  • Materialize on claim, not on every run. Avoids creating catalogs for bounces; the lead is already captured in Attio regardless.
  • Destination = deep-link to the populated catalog, not chat root.

Source references

  • Auth: chat/providers/PrivyProvider.tsx (now @privy-io/react-auth@^3.31.0), marketing/app/valuation/ValuationAuthProvider.tsx (^3.31.0), chat/hooks/useUser.tsx, chat/components/SignInPage/SignInPage.tsx
  • api auth: api/lib/auth/{validateAuthContext,getAuthenticatedAccountId,getApiKeyAccountId,getBearerToken}.ts, api/lib/networking/getCorsHeaders.ts, api/lib/mcp/verifyApiKey.ts
  • Valuation → data: api/lib/research/measurement_jobs/createMeasurementJobHandler.ts, api/lib/research/playcounts/createSnapshot.ts, GET /api/research/albums/{id}/measurements
  • Catalog model: tables catalogs, account_catalogs, catalog_songs, songs (ISRC), song_artists, playcount_snapshots, song_measurements; endpoints GET /api/accounts/{id}/catalogs, GET /api/catalogs/songs
  • chat catalog UI: chat/app/catalogs/[catalogId]/page.tsx, chat/components/Catalog/*, chat/lib/catalog/{getCatalogs,getCatalogSongs}.ts
  • marketing handoff: marketing/components/valuation/* ("Get the full report" CTA), marketing/lib/valuation/claimCatalog.ts, marketing/hooks/useGetFullReport.ts
  • Parent: chat#1798. Related (separate): chat#1799 lead-route auth.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions