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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions