diff --git a/CLAUDE.md b/CLAUDE.md index 31d0ee0..f1c7110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,8 @@ A `.env` file is required with the following variables: - `VITE_CANONICAL_URL` - Canonical URL for the application - `PODPING_ENDPOINT_URL` - Full URL to MSP's self-hosted podping-hivepinger Railway service, trailing slash (optional; podping notifications are skipped when unset) - `PODPING_BEARER_TOKEN` - Bearer token shared with the Railway service (optional; podping notifications are skipped when unset) +- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` - Google OAuth client credentials for managed-keypair sign-in (optional; `/api/auth/google-start` returns 503 "not configured" when unset) +- `AUTH_SECRET` - HKDF root secret used to encrypt every stored managed nsec and to sign session JWTs (`api/_utils/authUtils.ts`). **Load-bearing & must stay fixed** — rotating it orphans all stored managed keypairs (they become undecryptable) No `.env.example` exists - request credentials from the team. @@ -32,12 +34,23 @@ npm run dev ## Deployment -- Hosted on Vercel at msp.podtards.com +- Hosted on Vercel. Canonical domain is **musicsideproject.com**; `msp.podtards.com` is a legacy alias of the same project (still resolves, but must not appear in newly generated URLs) - API functions in `/api/` directory are Vercel serverless functions -- Dev server proxies `/api/*` to production +- Dev server proxies `/api/*` to production (`musicsideproject.com`) - Build: `npm run build` (tsc + vite) - Build auto-unshallows Vercel's git clone for accurate version computation +### Typechecking — use `npm run build`, not `tsc --noEmit` +The root `tsconfig.json` is **references-only** (`files: []` + references to `tsconfig.app.json` / `tsconfig.node.json`), so `tsc --noEmit` against it checks **zero files** and always passes — a false green. Always verify types with `npm run build` (which runs `tsc -b && vite build`) or at minimum `npx tsc -b`. (Lint is separate: `npm run lint`.) + +### Canonical feed domain +All hosted feed URLs (album/video/publisher) use the canonical domain, never the request host: +- `getBaseUrl()` (`api/_utils/feedUtils.ts`) returns `process.env.CANONICAL_URL` (default `https://musicsideproject.com`) — it ignores the request host so a feed created on any alias/preview deploy still reports the canonical URL. +- `buildHostedUrl()` (`src/utils/hostedFeed.ts`) uses `VITE_CANONICAL_URL` (default `https://musicsideproject.com`). +- MSP-hosted detection (`isMspUrl` in `xmlParser.ts`, `isMspHosted` in `publisherPublish.ts`) and the `/api/proxy-feed` allowlist match both `musicsideproject.com` and the legacy `msp.podtards.com`. +- The `/api/hosted/[feedId].xml` GET serves `Content-Type: application/xml` so browsers render feeds inline in a tab instead of downloading (podcast apps parse either XML content-type identically). +- Changing the canonical only affects **newly generated/re-saved** URLs; feeds already registered in Podcast Index keep their original URL until re-saved. + ### Versioning Version is auto-computed at build time from git commit count: `0.1.{count - 255}` (zero-padded). Each push to master increments the patch number. Configured in `vite.config.ts` via `getAutoVersion()`, with `package.json` version as fallback when git is unavailable. Displayed in the hamburger menu. @@ -112,7 +125,7 @@ gh issue view # View issue details ## Commands ```bash -npm run dev # Start Vite dev server (proxies /api to msp.podtards.com) +npm run dev # Start Vite dev server (proxies /api to musicsideproject.com) npm run build # TypeScript compile + Vite build npm run lint # ESLint npm run test # Run tests with Vitest @@ -122,11 +135,12 @@ npm run preview # Preview production build ## Architecture -### Three Feed Modes -The app has three modes selected via dropdown in the header: +### Four Feed Modes +The app has four modes selected via dropdown in the header: - **Album** - Music album RSS feeds with tracks - **Video** - Video feed RSS (similar structure to Album) - **Publisher** - Label/publisher catalog feeds that aggregate multiple album feeds +- **Artist (Album + Publisher)** - Combined editor that renders the Album editor stacked above the Publisher editor with cross-linked GUIDs. First-time-artist onboarding path so users don't context-switch between two modes to set up a single release + label catalog. Selecting it from the dropdown auto-creates whichever side is missing in `handleSwitchFeedType('artist')` (App.tsx) and routes to `` (`src/components/Editor/ArtistEditor.tsx`), which composes `` + `` (both gained a `chromeless?: boolean` prop so they can render without their own `main-content` / `editor-panel` wrapper). The bottom of the combined view renders `` — an inline guided-publish panel; see "Artist mode" subsection below. ### State Management Uses React Context + useReducer pattern (not Redux). Three separate stores: @@ -137,7 +151,7 @@ Uses React Context + useReducer pattern (not Redux). Three separate stores: Actions are dispatched via reducer pattern. The `FeedAction` union type in `feedStore.tsx` defines all available actions. ### Core Data Types (src/types/feed.ts) -- `FeedType` - `'album' | 'video' | 'publisher'` (canonical definition, re-exported from `feedStore.tsx`) +- `FeedType` - `'album' | 'video' | 'publisher' | 'artist'` (canonical definition, re-exported from `feedStore.tsx`; `'artist'` is the combined-editor UI mode — see "Four Feed Modes") - `Album` - Feed metadata + array of `Track`s - `Track` - Individual items with optional per-track value recipients - `Person` - Contributors with roles (uses Podcasting 2.0 taxonomy) @@ -150,11 +164,14 @@ Vercel serverless functions: - `pisearch.ts` - Podcast Index search - `pisubmit.ts` - Submit feed to Podcast Index - `pubnotify.ts` - Podcast Index pub notification + feed lookup; accepts optional `medium` query param and fire-and-forgets a podping via `notifyPodping()` in parallel so the toolbar "Podcast Index" button hits both indexing pathways +- `pi-resolve.ts` - **read-only** GET `?guid=` → resolves a feed's numeric Podcast Index page (`https://podcastindex.org/podcast/`) via `lookupPodcastIndexId()` (byguid). NO side effects (no pubnotify ping / podping / `add/byfeedurl`), unlike `pubnotify.ts` — so it's **safe to poll** while PI finishes indexing a freshly published feed. The wizard's post-publish screen polls it (see below) - `podping.ts` - Broadcast feed update via self-hosted hivepinger Railway service (requires `PODPING_ENDPOINT_URL` + `PODPING_BEARER_TOKEN`); rate-limited 10/hour per IP - `proxy-feed.ts` - CORS proxy for fetching external feeds - `hosted/` - MSP feed hosting endpoints (create, update, delete, backup/restore) - `feed/[npub]/[guid].ts` - Nostr-stored feed retrieval - `admin/` - Admin authentication (challenge/verify) +- `auth/` - **Google OAuth → managed Nostr keypair sign-in** (currently on `new-onboarding-v2` only, not yet on master). `google-start.ts` redirects to Google's consent screen (builds `redirect_uri` from the request `Host` header — so every host that serves this needs its own Authorized Redirect URI registered in the Google OAuth client, or Google rejects with `redirect_uri_mismatch`); `google-callback.ts` exchanges the code, creates-or-loads the user's encrypted keypair blob, and sets the session cookie; `keypair.ts` returns the decrypted secret key (hex) for the logged-in user so the client can init a signer; `me.ts` returns the session user (pubkey/npub/email/displayName/picture) from the JWT; `logout.ts` clears the session cookie +- `_utils/authUtils.ts` - Managed-keypair crypto + session helpers: HKDF-derives an AES-GCM key from `AUTH_SECRET`+userId to `encryptNsec`/`decryptNsec`, `signJwt`/`verifyJwt` (30-day sessions), session-cookie helpers, and blob path/prefix helpers (`sha256(googleId)`, stored via `put(addRandomSuffix: true)` so the object name isn't guessable). Encrypted keypairs live in Vercel Blob (`BLOB_READ_WRITE_TOKEN`) - `_utils/podcastIndex.ts` - Shared Podcast Index auth headers - `_utils/feedUtils.ts` - Shared feed utilities (PI notification, podping notification, `isPodpingConfigured()` helper, UUID validation, token hashing) - `_utils/rateLimiter.ts` - In-memory fixed-window IP rate limiter used by `/api/podping` @@ -171,6 +188,7 @@ Vercel serverless functions: - **Backup retention**: `backupFeed()` helper in `api/hosted/[feedId].ts` creates timestamped backups before PUT, DELETE, and restore operations; keeps only the 10 most recent backups per feed - **Podping**: `notifyPodcastIndex()` fire-and-forgets `notifyPodping()` after the PI pubnotify ping. Sends `GET ${PODPING_ENDPOINT_URL}?url=...` with `Authorization: Bearer ${PODPING_BEARER_TOKEN}`. The endpoint is MSP's self-hosted [podping-hivepinger](https://github.com/brianoflondon/podping-hivepinger) deployment on Railway (repo: `ChadFarrow/msp-podping-service`), fronted by a Caddy sidecar enforcing the bearer token. Silently no-ops when either env var is unset (`isPodpingConfigured()` in `api/_utils/feedUtils.ts` is the canonical gate). The fire-and-forget call site uses a `.then()` that `console.warn`s on failure so Vercel function logs surface hivepinger outages. `/api/podping` exposes a manual endpoint behind a 10/hour per-IP rate limit. `/api/pubnotify` also fires a podping (same fire-and-forget pattern) so the "Podcast Index" toolbar button hits both indexing pathways. UI entry point for a pure podping (no PI call): the standalone **Podping** button on the bottom toolbar (`PodpingModal.tsx` — opens a mini modal with just a URL field + submit, `reason` is hardcoded to `'update'`). The SaveModal previously had a "Send Podping" destination; it was removed in favor of the dedicated toolbar button. - **Podping `medium` — load-bearing**: hivepinger uses the `medium` value to build the custom_json op id as `pp__` (e.g. `pp_music_update`). The companion consumer in `msp-podping-service` filters `pp_music_*` only, so any code path that fires a podping WITHOUT a medium ends up as `pp_podcast_update` (hivepinger's default) and is invisible to the consumer. Every client path that can trigger a podping passes medium: hosted POST/PUT (extracted via `extractPodcastMedium()` from the XML), SaveModal's nsite follow-up and "Submit to PodcastIndex" destination (`album.medium` / `publisherFeed.medium`), `publisherPublish.ts`'s internal `notifyPodcastIndex()` helper (takes a `medium` param forwarded to `/api/pubnotify`), PublisherFeedReminderSection (`publisherFeed.medium`). The PodpingModal toolbar button reads medium from the feed (`album.medium` / `videoFeed.medium` / `publisherFeed.medium`), matching the SaveModal pattern. Publisher feeds carry `medium: 'publisher'` which produces `pp_publisher_update` — still filtered out by the music-only consumer, preserving the prior intent without special-casing. When adding a new podping trigger, always plumb through the feed's medium — the `isPodpingConfigured()` gate + `notifyPodping(url, { medium })` signature is the canonical call site pattern. +- **PI publisher-feed indexing — partially understood, parked**: empirically, `add/byfeedurl` works reliably for `medium=music` feeds (the album shows up in PI within ~5 min) but does NOT reliably auto-index `medium=publisher` feeds when submitted via our `/api/hosted` flow. PI returns an empty body for the publisher submission (just like it does for music; empty body isn't a rejection signal), but the publisher either never gets parsed or gets registered with empty metadata (`title=""`, an auto-generated UUID-v5 `podcastGuid` derived from the URL, no image). Manual submission via podcastindex.org's web form succeeds for the same XML, so PI's web form and API endpoints aren't the same code path on PI's side. Fixes attempted (`8557c7d` cross-link `feedUrl` injection, `99c89eb` empty-recipient filter) helped but weren't sufficient on test feeds. Candidate causes still to rule out: heuristic spam filter on "test"-heavy content, PI API key write-permission level, single thin remoteItem catalog vs. Longy-style 14-item catalogs, Vercel cold-start during PI's first crawl. The verify step's polling cycle includes a "check manually →" fallback link to `podcastindex.org/search?q=` when a feed doesn't land within the budget. See PR #63 conversation for the full diagnostic trail. ### Save Modal Destinations The Save modal (`src/components/modals/SaveModal.tsx`) offers nine destinations. Each is a different combination of *where the bytes live* and *who can consume them* — important context when deciding which one to point a user at: @@ -180,8 +198,10 @@ The Save modal (`src/components/modals/SaveModal.tsx`) offers nine destinations. | Local Storage | Album/Video/Publisher state | Browser localStorage | No | | Download XML | Generated RSS XML | User's filesystem | No | | Copy to Clipboard | Generated RSS XML | Clipboard | No | -| Host on MSP | Generated RSS XML | Vercel Blob (`feeds/{feedId}.xml`) | Yes — `https://msp.podtards.com/api/hosted/{feedId}` | +| Host on MSP | Generated RSS XML | Vercel Blob (`feeds/{feedId}.xml`) | Yes — `https://musicsideproject.com/api/hosted/{feedId}.xml` | +| Host on MSP (album + publisher) | Both XMLs in sequence; Artist mode only when `isLoggedIn` | Vercel Blob (one entry per feed) | Yes — both feeds hosted with cross-link `feedUrl`s injected; see `hostBothOnMSP` in `src/utils/artistPublish.ts`. The dropdown swaps the single "Host on MSP" option for this combined variant when `feedType === 'artist'`. | | Submit to PodcastIndex | Feed URL (not the bytes) submitted to PI via `/api/pubnotify` | — (registration only) | Indirectly — PI indexes the URL so apps like Fountain/Castamatic can discover it | +| Download Feed Package (album + publisher) | Both XMLs + `next-steps.txt` | User's filesystem | No (export only); paired with Artist mode for users who self-host | | Save RSS feed to Nostr | Full RSS XML embedded in a kind 30054 event | Nostr relays only | No — only MSP reads kind 30054 (cross-device sync) | | Publish to Nostr Music | Per-track events (kind 36787) + playlist event (kind 34139) | Nostr relays | No — Nostr-native music clients only (Wavlake, Fountain, etc.). Audio files must already be hosted elsewhere; the events just reference enclosure URLs | | Publish RSS feed to a Blossom server | Generated RSS XML | Blossom server (content-addressed) + kind 1063 NIP-94 pointer event on Nostr | Yes — `${origin}/api/feed/{npub}/{podcastGuid}.xml` resolves the pointer and 302s to the latest Blossom URL | @@ -191,9 +211,55 @@ Login-gated options (everything from "Save RSS feed to Nostr" down) are conditio Most experimental/power-user options are additionally gated behind a "Show Experimental Features" toggle in the hamburger menu (`src/store/experimentalStore.tsx`, localStorage key `msp-show-experimental`, default off). With the toggle off, the Save modal dropdown collapses to the production-ready set: Local Storage, Download XML, Copy to Clipboard, Host on MSP, Submit to PodcastIndex, and (when logged in) Publish to Nostr Music. The Import modal applies the same gate to "Nostr Event" and "From Nostr." When adding a new experimental destination/import source: gate it with `showExperimental` from `useExperimental()`, suffix the visible label with a trailing ` 🧪` marker, sort it to the bottom of its dropdown and help-list (after all non-experimental options), and add a mode-reset `useEffect` so the dropdown snaps back to a safe default if the user flips the toggle off mid-flow. The experimental store follows the same Provider+`useX()`-in-one-file pattern as the other stores (`themeStore`, `feedStore`, `nostrStore`); `eslint.config.js` carves out `react-refresh/only-export-components` for `src/store/*.{ts,tsx}` since these are plumbing files, not fast-refresh-sensitive UI. +### Artist mode (combined editor) +First-time-artist onboarding flow that creates both an album feed and a publisher catalog with cross-linked GUIDs in a single workspace. +- **Entry points**: top header dropdown's "Artist (Album + Publisher)" option (canonical); the New Feed Choice modal's "Artist Setup" button (album mode only) is a shortcut that calls `handleSwitchFeedType('artist')` + closes the modal. +- **Auto-create logic** (`handleSwitchFeedType('artist')` in `src/App.tsx`): if `state.publisherFeed` is missing, creates one with `remoteItems: [{ feedGuid: albumGuid, feedUrl: '', title: '', medium: 'music' }]`. If `state.album` is missing, creates one with `publisher: { feedGuid: publisherGuid }`. If both exist but the album's `publisher.feedGuid` doesn't match the publisher's `podcastGuid`, dispatches `UPDATE_ALBUM` to fix it. If the publisher exists but its `remoteItems` doesn't include the current album, appends a remoteItem (reconciliation — without this the hosted publisher XML carries a stale cross-link from a prior session). After all reconciliation dispatches, explicitly dispatches `SET_FEED_TYPE: 'artist'` to override the implicit `'album'` / `'publisher'` that `SET_ALBUM` / `SET_PUBLISHER_FEED` set. +- **`handleStartBlank` artist branch**: when `pendingNewFeedType === 'artist'`, generates two fresh GUIDs and dispatches SET_ALBUM + SET_PUBLISHER_FEED with cross-links + restores feedType. Without this, "New → Start Blank" while in artist mode silently demoted to album mode (the original PR #63 bug). +- **``** (`src/components/Editor/ArtistEditor.tsx`): thin composition that renders `` + `` inside one outer `main-content/editor-panel` wrapper. Both editors gained an optional `chromeless?: boolean` prop (default false) that, when true, skips their own wrappers — used here so Artist mode has one outer scroll container instead of two nested ones. Section-header bars (indigo for Album, violet for Publisher) demarcate the two halves visually. In artist mode, `` hides the "Publisher Feed (Advanced)" Section entirely (redundant — publisher fields are below). `` hides `CatalogFeedsSection`, `PublisherFeedReminderSection`, `DownloadCatalogSection`, and `PublishSection` (all noise during first-time setup; the artist is building one album + one publisher, not managing a catalog). +- **``** (`src/components/Editor/ArtistPublishSection.tsx`): inline guided-publish panel at the bottom of the artist editor. Two primary actions: + - **Host on MSP — album + publisher (one click)** — calls `hostBothOnMSP(album, publisher, userPubkey, onStep)` from `src/utils/artistPublish.ts`. Gated on Nostr login (the helper uses `createHostedFeedWithNostr` / `updateHostedFeedWithNostr` exclusively — no edit-token path for this combined flow). Disabled until both feeds have a non-empty title (PI silently drops empty-title feeds). + - **Download Feed Package (host yourself)** — calls `downloadArtistFeedPackage(album, publisher)` from the same util; emits album XML + publisher XML + a `next-steps.txt` instructions file. Staggered `setTimeout` (400ms/800ms) between downloads to avoid Chrome's multi-download blocker. + - **Live step list**: three rows (album-host, publisher-host, verify-index) that transition pending → in-progress → done. Verify polls `/api/pisearch?q={guid}` for both feeds on a backoff schedule (`POLL_DELAYS_MS` in `artistPublish.ts`: 20s/30s/60s/120s, ~10.5 min total budget). Each tick emits a `VerifyProgress` event so the UI can render a countdown ("Checking again in 30s · attempt 5 of 12") and per-feed status. + - **Refresh resume**: on mount, the component reads `getHostedFeedInfo()` for both feeds. If both are hosted in this browser, it hydrates `result` with the URLs, marks the host steps ✓ done, and kicks off a fresh verification poll automatically — so a page refresh doesn't trick the user into re-hosting. The Host Both button stays clickable for legitimate re-hosts (label changes to "Re-host both feeds (update with latest XML)"). + - **Cancellation**: a `CancellationToken` (via `useRef`) is set up per polling session; unmount or a re-click flips `cancelled = true` so background polls don't setState on dead components. +- **`hostBothOnMSP` cross-link injection** (`src/utils/artistPublish.ts`): the `/api/hosted` endpoint uses `podcastGuid` as the URL `feedId`, so both feeds' hosted URLs are deterministic *before* upload. `hostBothOnMSP` precomputes `buildHostedUrl(album.podcastGuid)` and `buildHostedUrl(publisherFeed.podcastGuid)` and patches them into the album's `` reference and the publisher's `` before serializing the XMLs. Only fills in `feedUrl` when the user hasn't set one manually (preserves user-set externally-hosted publisher URLs). Returns the injected URLs as `injectedAlbumPublisherFeedUrl` / `injectedPublisherRemoteItemFeedUrl` so the caller can dispatch UPDATE actions to reflect them in the in-store feeds (the editor then shows the cross-links the user just shipped). +- **Test data**: `🧪 Load Test Data` in the hamburger menu (gated behind Show Experimental Features) is artist-aware. In artist mode it dispatches both `generateTestAlbum()` and `generateTestPublisher()` (the latter added in `src/utils/testData.ts`) with cross-linked GUIDs and explicitly restores `feedType: 'artist'` after (since `SET_ALBUM` resets feedType to `'album'`). In other modes the original album-only behavior is preserved. + +### First-run onboarding wizard (`new-onboarding-v2` branch) +A guided flow that takes a brand-new artist from zero to a live hosted feed without touching the full editor. **Branch-only**: `new-onboarding-v2` is a long-lived, separately-deployed build (powers `new.musicsideproject.com`); it is intentionally NOT merged to `master`. Nostr-only auth throughout (no edit-token path). (Supersedes the v1 single-file `ArtistOnboardingWizard.tsx`, which no longer exists on this branch.) +- **Gate + entry points**: on first visit `App.tsx` renders `` (`src/components/OnboardingPage.tsx`) when `!onboardingStorage.isComplete() && !wizardStorage.isComplete()` — the "Have you used MSP 2.0 before?" gate. The gate has two in-gate screens (`gateView: 'ask' | 'hosting'` state in `OnboardingPage`): + - **"No, I'm new"** → a second screen, **"Where will your feed live?"**, that forks on *hosting choice* — **self-host vs MSP-host**, which is orthogonal to Nostr/Lightning (those stay optional on either side): + - **"I'll host it myself"** (`onChooseSelfHost`) → marks onboarding complete, closes the gate, drops into the **plain album editor** (`SET_FEED_TYPE 'album'`) — no account, no wizard, no Nostr/Bitcoin shown. They build the feed, Download XML, and self-host. The sequential "album now, publisher later (built via PI search)" flow is the natural fit, because URL cross-linking needs real URLs that only exist after hosting. + - **"Let MSP host it for me"** (`onChooseMspHost`) → marks complete, `handleSwitchFeedType('artist')`, opens the wizard (`wizardStorage.markInProgress()` + `setShowArtistWizard(true)`). The both-at-once combined flow is inherently the MSP-host experience (one-click hosting is what mints both URLs and cross-links them instantly). + - **"Yes, I've used this before"** (`onChooseReturning`) → marks complete and closes the gate, landing in the editor. Sign-in is **offered, not forced** (no auto-opened modal — a self-host returner must not hit a sign-in wall); the header Sign In is the standing offer, and the **Artist Profile auto-route** (see its section below) sends a signed-in owner of ≥1 hosted feed to their profile. + - Also reachable via **New → "New Artist (Guided)"** (`NewFeedChoiceModal`). localStorage: `msp2-onboarding-complete` / `msp2-wizard-complete` (`onboardingStorage` / `wizardStorage` in `src/utils/storage.ts`). **Self-host/MSP-host is purely about where the feed bytes live** — a musician who wants nothing to do with Nostr or Bitcoin/Lightning must be able to use MSP end-to-end via the self-host path (and funding links are the fiat-friendly support option alongside V4V). + - **Testing aid**: load `?onboarding=1` once to force the gate on every load regardless of saved completion (sticky via localStorage key `msp:force-onboarding`); `?onboarding=0` disarms. `App.tsx`'s `forceOnboarding` IIFE. No-op in normal use. +- **``** (`src/components/Onboarding/OnboardingWizard.tsx`, default export) — a slim **full-page dialog** container (`.onboarding-page*` classes in `App.css`, shared with `OnboardingPage`; own Escape-to-close + mount focus). It owns the header/rail/footer chrome, the step-gated `useEffect`s, and `handlePublish`; all wizard state/logic lives in the **`useOnboardingDraft`** hook (`src/components/Onboarding/useOnboardingDraft.ts`). Each step's UI is its own presentational component under `src/components/Onboarding/steps/{Intro,Auth,Publisher,Album,Tracks,Value,Extras,Review}Step.tsx`, each taking the shared `w: OnboardingDraft` (= `ReturnType`) prop; the wizard body is just eight `{step === 'x' && }` lines. Shared pieces: `NostrLoginPanel.tsx` (login UI), `ReviewSummary.tsx` (review card), `CopyableUrlRow.tsx` (label + read-only URL + Copy button + help text, used for the post-publish feed URLs). The **only** wizard exit is the **top-right ✕** (`handleDismiss` = `wizardStorage.markComplete()` + `onComplete()`; Escape does the same), which closes the wizard into the New Artist editor preserving whatever was entered (the wizard writes to the same `feedStore`). There is intentionally no footer "skip" button — it sat flush against the primary Next and invited mis-clicks. + - **Steps** (`STEP_ORDER` in `useOnboardingDraft.ts`): `intro · auth · publisher · album · tracks · value · extras · review` (8 steps, same for new and returning artists). Each non-`intro`/`auth` step renders the **same real editor sections** the main editor uses (`AlbumInfoSection`, `AlbumArtworkSection`, `TrackList`, `PersonsSection`, `PublisherInfoSection`, `RecipientsList`, `FundingFields`) so there's one source of truth and full field parity — no parallel simplified fields. + - **intro**: static overview page (what MSP does + sign-in→build→publish flow) with a "Get started →" CTA. Plain `.onboarding-step` block, not a `
`. + - **auth**: a **three-way sign-in chooser** (`AuthStep.tsx`, local `choice` state: `'choose' | 'google' | 'new' | 'have'`) shown when logged out — **Just use Google** (`` → managed keypair, recommended), **Try Nostr — I'm new** (`` — a two-view Primal flow, see the dedicated bullet below), **I already have Nostr** (``: NIP-07 / bunker URI / inline NIP-46 QR). Each path has a **← Back** link to the chooser. `GoogleSignInButton`/`NewToNostrPanel` (`src/components/Onboarding/`) are shared with `NostrConnectModal` (single source of truth). The Google flow is a **full-page redirect**, so App.tsx persists a `wizardStorage.isInProgress()` flag (set when the wizard opens, cleared on close/finish) and initializes `showArtistWizard` from it — the OAuth round-trip re-opens the wizard at its saved step. Once signed in (any method) the step shows the identity card + a **Sign out** button (`nostrStore` `logout`). On sign-in, `onSignedIn` runs the existing-publisher lookup (`loadPublisherFeedsFromNostr`) and shows either a **Continue** (new artist) or a **publisher chooser** (returning artist). The lookup **re-runs on every entry** (re-sign-in after sign-out, Back to auth); `lookingUpRef` only guards overlapping calls, and an npub-change effect drops stale choices on account switch. `choosePublisher` sets the chosen feed and lands on the **publisher** step (pre-filled); `startNewPublisher` creates a locked shell. + - **"Try Nostr — I'm new" flow** (`NewToNostrPanel`, two internal views via a `view: 'steps' | 'connect'` state): (1) **steps** — a centered intro line (create your Primal account; download the **app** from the App Store / Google Play — not Primal web, which can't act as the NIP-46 signer) + `` (`src/components/Onboarding/`): a two-column walkthrough of the 5 Primal account-creation screenshots (`src/assets/onboarding/primal-1..5-*.webp`) on the left + a numbered step checklist on the right; the 6th checklist item **"Connect to MSP"** is locked until the 5 setup steps are reached and, when clicked, switches to the connect view. (2) **connect** — a dedicated 3-column page (`primal-menu`/`primal-remote-login`/`primal-connect-login`/`primal-connect-permissions` screenshot ‖ 4 numbered steps ‖ the QR). The QR is `` — a variant that auto-generates the `nostrconnect://` QR and shows only the code + waiting state (no extension/bunker UI). Verified Primal path: **profile avatar → Remote Login** (a QR scanner); MSP shows the QR, Primal scans it, the user picks an account + trust level → Connect. `NostrLoginPanel` (full variant) leads with a QR-first "Scan a QR code to connect" button; `bunker://` paste is a secondary "Advanced" toggle. + - **publisher** ("Your artist identity"): `PublisherInfoSection` — note **Artist Name → `author`**, **Catalog Title → `title`**. Shown for everyone, including returning artists (pre-filled with their chosen feed). `ensurePublisherShell` (HARD tie: `locked`/`lockedOwner`) runs only for NEW artists, so picking an existing publisher is never overwritten. The **"Use my Nostr name & photo"** button (`pullProfileFromNostr`) is hidden for **managed (Google) keys** (`connectionMethod === 'managed'`) — a fresh managed keypair has no kind-0 profile to pull. + - **value** (`ValueStep.tsx`, "Value / V4V"): Lightning V4V `RecipientsList` **+** the `` "Support link" (`FundingFields` → `album.funding`) — the funding tag lives here, not in extras. For **managed (Google) keys** (`connectionMethod === 'managed'`) the V4V block is hidden entirely (no Lightning wallet) — only the Support link shows; NIP-07/NIP-46 keep the full V4V section. + - **extras** (`ExtrasStep.tsx`, "Credits & extras"): Credits/Persons only (funding moved to the value step). `PersonsSection` takes two opt-in props used here: **`hideNpub`** (passed for managed/Google users — hides the per-person Nostr npub field, since they have no Nostr context) and **`myNpub`** (the logged-in Nostr user's npub for non-managed users — renders a **"use mine"** button next to each npub field that fills in your own, for crediting yourself). Both default off, so `TrackList`'s per-track persons and the main editor are unaffected. + - **review**: `ReviewSummary` (custom component) — Artist/Publisher block shows Artist Name + Catalog Title as distinct rows; each track with an `enclosureUrl` renders a native **`