Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
150 commits
Select commit Hold shift + click to select a range
1b0cc6d
Add first-time artist setup flow for album + publisher feeds
claude May 21, 2026
e9f8cc6
Add first-visit onboarding guide modal
claude May 21, 2026
8346430
WIP: convert onboarding to full-page with first-visit gate
ChadFarrow May 21, 2026
e49045b
Add combined artist editor design spec
ChadFarrow May 21, 2026
a7d945a
Add combined artist editor implementation plan
ChadFarrow May 21, 2026
1c51d04
Add 'artist' to FeedType union and feedTypeStorage allowlist
ChadFarrow May 21, 2026
4ee9d33
Add Artist option to feed-type dropdown
ChadFarrow May 21, 2026
11cf290
Auto-create cross-linked album+publisher when switching to Artist mode
ChadFarrow May 21, 2026
a3099a7
Add chromeless prop to Editor and PublisherEditor for composition
ChadFarrow May 21, 2026
4e010c9
Add ArtistEditor combined view and route 'artist' feedType to it
ChadFarrow May 21, 2026
ffad56f
Hide Editor's Publisher-Feed banner in Artist mode
ChadFarrow May 21, 2026
4a0f206
Fix CatalogFeedsSection dead-code condition for current-album shortcut
ChadFarrow May 21, 2026
d9cf4d2
Simplify handleArtistSetup to delegate to handleSwitchFeedType
ChadFarrow May 21, 2026
0f1cad0
Handle Start Blank and tooltip for Artist mode
ChadFarrow May 21, 2026
4b26f03
Hide Publisher Feed (Advanced) section entirely in Artist mode
ChadFarrow May 21, 2026
ddf9b3a
Suppress catalog/publish UI in PublisherEditor when in Artist mode
ChadFarrow May 21, 2026
78b2ce4
Add Host on MSP (album + publisher) for Artist mode
ChadFarrow May 21, 2026
74edd15
Add inline ArtistPublishSection with guided publish actions
ChadFarrow May 21, 2026
7d8f7c7
Remove 'More save options' link from ArtistPublishSection
ChadFarrow May 21, 2026
e6252d0
Show live 3-step progress + PI verification after Host Both
ChadFarrow May 21, 2026
c414d37
Extend PI verification polling + lock Host Both after first click
ChadFarrow May 21, 2026
c03b61f
Guard Host Both on titles + reconcile stale publisher remoteItem
ChadFarrow May 21, 2026
a244ffa
Make Load Test Data populate publisher too when in Artist mode
ChadFarrow May 21, 2026
46bd639
Slow PI verification schedule to match real crawl latency
ChadFarrow May 21, 2026
5b627e0
Resume hosted state + PI verification on page refresh
ChadFarrow May 21, 2026
f1caac5
Surface non-JSON / empty PI responses in /api/pisubmit
ChadFarrow May 22, 2026
8557c7d
Inject feedUrl cross-links in Host Both + verify album only
ChadFarrow May 22, 2026
4f5ce8f
Allow re-host after first session completes
ChadFarrow May 22, 2026
4531e81
Re-add publisher to PI verification step
ChadFarrow May 22, 2026
99c89eb
Strip empty placeholder valueRecipients from emitted XML
ChadFarrow May 22, 2026
dbf9b67
Document Artist mode + PI publisher-indexing parked issue in CLAUDE.md
ChadFarrow May 22, 2026
dda5345
Refactor: extract EditorChrome, use createEmptyRemoteItem, polish
ChadFarrow May 22, 2026
204aeb8
Extract buildArtistSetupActions + generateLinkedTestArtistFeeds
ChadFarrow May 22, 2026
9547c40
Return patched feeds from hostBothOnMSP
ChadFarrow May 22, 2026
0ddff0b
Add onboarding polish + Lightning feature-hiding questionnaire
ChadFarrow May 30, 2026
dd169c9
Merge onboarding guide + Lightning questionnaire into Artist branch
ChadFarrow May 30, 2026
265e4ec
Route first-time users to Artist setup; rename to "New Artist"
ChadFarrow May 30, 2026
cde80c0
Hide OP3 analytics + GUID fields in Artist mode
ChadFarrow May 30, 2026
2afe178
Gate per-track value recipients on the Lightning toggle
ChadFarrow May 30, 2026
a1d7cef
Add Blossom media upload for audio tracks
claude May 30, 2026
9b1ec1f
Add Blossom upload for artwork images
claude May 30, 2026
36dee2e
Add Blossom upload to track art, lyrics, and person photo fields
claude May 30, 2026
ee757a5
Add Upload Mode toggle — replaces URL fields with upload boxes
claude May 30, 2026
4c6d703
Trim Blossom server list to primal.net and nostr.download
claude May 30, 2026
fd907ab
Make Blossom upload coexist with URL fields; add timeout + retry
ChadFarrow May 31, 2026
395fee1
Document Blossom media upload in CLAUDE.md
ChadFarrow May 31, 2026
7f9ec36
Add dead-simple new artist onboarding wizard
claude May 31, 2026
a87a360
Expand wizard Step 2 with all required feed fields
claude May 31, 2026
f9350e6
Remove edit token system — Nostr auth only for hosted feeds
claude May 31, 2026
3cff432
Merge remote-tracking branch 'origin/Blossom-Hosting' into new-onboar…
ChadFarrow May 31, 2026
aafb6db
Merge remote-tracking branch 'origin/feature/artist-onboarding' into …
ChadFarrow May 31, 2026
93b96aa
Fix App.tsx + NewFeedChoiceModal merge resolution
ChadFarrow May 31, 2026
49e79f4
Fix pre-existing ESLint errors
ChadFarrow May 31, 2026
206f99f
Convert ArtistOnboardingWizard from modal to full-page dialog
ChadFarrow Jun 1, 2026
a0085bb
Fix wizard publish correctness for Nostr login + Blossom hosting
ChadFarrow Jun 1, 2026
926e129
Flesh out onboarding wizard: QR login, multi-track, payment & details
ChadFarrow Jun 1, 2026
52f3d84
Replace 4-step artist wizard with 7-step onboarding wizard
ChadFarrow Jun 1, 2026
893f731
Polish onboarding wizard: plain tooltips, layout, either/or media inputs
ChadFarrow Jun 2, 2026
7855bd9
Restyle wizard track rows as collapsible cards (main-editor style)
ChadFarrow Jun 2, 2026
9db6180
Auto-calculate the artist V4V split as the remainder
ChadFarrow Jun 2, 2026
f28c207
Add Credits / Persons editor to the wizard's Credits step
ChadFarrow Jun 2, 2026
4e7fe17
Extract album editor sections into AlbumEditor/ components
ChadFarrow Jun 2, 2026
1f5214e
Extract TrackList from Editor.tsx (verbatim)
ChadFarrow Jun 2, 2026
61b273c
Rewire onboarding wizard to the shared editor sections
ChadFarrow Jun 2, 2026
db19314
Show a full summary on the wizard Review step
ChadFarrow Jun 2, 2026
0a95218
Improve onboarding wizard flow, navigation, and post-publish UX
ChadFarrow Jun 3, 2026
efed7a2
Update CLAUDE.md for the v2 onboarding wizard
ChadFarrow Jun 3, 2026
0399fe0
Refactor onboarding wizard into step components
ChadFarrow Jun 3, 2026
bef56c2
Update CLAUDE.md for the wizard step-component refactor
ChadFarrow Jun 3, 2026
08791ee
Merge master into new-onboarding-v2 — resolve conflicts
claude Jun 4, 2026
ce5311f
Gate wizard Next on all publish-required fields
ChadFarrow Jun 9, 2026
82be628
Show Back button on the wizard Artist/publisher step
ChadFarrow Jun 9, 2026
d694279
Add Google sign-in + managed Nostr keypair onboarding
claude Jun 18, 2026
f08f68b
Strip PII and randomize path for managed-keypair blob
ChadFarrow Jun 19, 2026
6707e7d
Document Google managed-keypair auth in CLAUDE.md
ChadFarrow Jun 19, 2026
775c495
Build three-way sign-in chooser in the wizard auth step
ChadFarrow Jun 19, 2026
19735f6
Add design spec: push Artist Name + artwork to Nostr profile (Google …
ChadFarrow Jun 19, 2026
7bcf584
Add implementation plan: Google managed-key profile push
ChadFarrow Jun 19, 2026
04fe099
Add mergeProfileFields: non-destructive kind-0 profile merge
ChadFarrow Jun 19, 2026
948ac71
Add publishProfileMetadata: publish a kind-0 Nostr profile
ChadFarrow Jun 19, 2026
08a710c
Expose updateProfile() on the Nostr store
ChadFarrow Jun 19, 2026
d88cba6
Push Artist Name + artwork to Nostr profile for managed keys
ChadFarrow Jun 19, 2026
d500e9b
Lead Nostr onboarding with QR scan; fix Primal copy
ChadFarrow Jun 20, 2026
24fd2ef
Add Primal signup screenshots + carousel design spec
ChadFarrow Jun 21, 2026
561f3d4
Show Primal signup as a screenshot carousel in New-to-Nostr panel
ChadFarrow Jun 21, 2026
7a5674d
Gate connect step behind completing the Primal carousel
ChadFarrow Jun 21, 2026
dcf9406
Size Primal carousel to viewport so the Sign-in step doesn't scroll
ChadFarrow Jun 21, 2026
295426e
Redesign Primal walkthrough as two-column phone + step checklist
ChadFarrow Jun 21, 2026
a907f6b
Collapse step checklist to a one-line summary once connect is shown
ChadFarrow Jun 21, 2026
b699b38
Fit Primal walkthrough on one screen: shorter phone column, caption i…
ChadFarrow Jun 21, 2026
8417fe4
Center the Primal walkthrough columns instead of spanning full width
ChadFarrow Jun 21, 2026
2914214
Make the Primal screenshot clickable to advance to the next step
ChadFarrow Jun 21, 2026
df01767
Let any Primal step in the checklist be clicked to jump to it
ChadFarrow Jun 21, 2026
b2ae75e
Mark optional Primal steps (interests, iCloud Keychain) as optional
ChadFarrow Jun 21, 2026
436dc02
Correct Primal step 2: at least one follow pack is required
ChadFarrow Jun 21, 2026
95133e3
Make "Connect to MSP" step 6 in the Primal checklist
ChadFarrow Jun 21, 2026
ad6fcc5
Anchor Primal walkthrough right column to a constant height
ChadFarrow Jun 21, 2026
ff55c8a
Fix Primal checklist boxes resizing per step (width:100% on carousel)
ChadFarrow Jun 21, 2026
c225ded
Dress up the Primal intro as a gradient callout with a Get Primal pill
ChadFarrow Jun 21, 2026
691864c
Simplify Primal intro: heading + app-store guidance, no links
ChadFarrow Jun 21, 2026
037905d
Drop "web version" clause; trim phone to stop the page scrolling
ChadFarrow Jun 21, 2026
56c1a2d
Combine Primal intro into a single line
ChadFarrow Jun 21, 2026
abf4428
Trim Primal intro to the download instruction
ChadFarrow Jun 21, 2026
7c05047
Center the Primal intro line
ChadFarrow Jun 21, 2026
a60c2c6
Make Connect to MSP its own QR-only page after the Primal steps
ChadFarrow Jun 21, 2026
c8074b9
Unlock Connect step when jumping to a step via the checklist
ChadFarrow Jun 21, 2026
7345510
Add numbered steps to the connect page; de-dupe the back button
ChadFarrow Jun 21, 2026
fe77c68
Use the real Primal Remote Login path in connect step 2
ChadFarrow Jun 21, 2026
3a9067f
Match connect page to the setup page layout
ChadFarrow Jun 21, 2026
43f6bbf
Add Primal Remote Login screenshot to a 3-column connect page
ChadFarrow Jun 21, 2026
7c478d1
Make connect page screenshot follow the active step (4-shot carousel)
ChadFarrow Jun 21, 2026
918572a
Wire all four Primal Remote Login screenshots into the connect page
ChadFarrow Jun 21, 2026
fc1db34
Explain trust levels in connect step 4
ChadFarrow Jun 21, 2026
9a98f72
Vertically center step text in the connect-page boxes
ChadFarrow Jun 21, 2026
f2a15ce
Remove dead code left from the Primal onboarding iteration
ChadFarrow Jun 21, 2026
1cd0847
Add auth-blob PII migration script and a feed backup
ChadFarrow Jun 21, 2026
6e6a56b
Fix slow profile name on sign-in: don't let a dead relay stall the fetch
ChadFarrow Jun 21, 2026
8992be6
Add check-profile.mjs diagnostic for kind-0 profile fetches
ChadFarrow Jun 21, 2026
18931d8
Make page-3 artist info authoritative for managed-key Nostr profile
ChadFarrow Jun 21, 2026
86a198f
Move the funding tag from Credits/extras (step 7) to the Value page (…
ChadFarrow Jun 21, 2026
89a8a25
Hide Lightning V4V on the Value step for Google (managed) users
ChadFarrow Jun 21, 2026
160492c
Update CLAUDE.md for the onboarding Nostr/Google flow changes
ChadFarrow Jun 21, 2026
a016406
Refine onboarding wizard: copy, one-publisher flow, tracks/value tweaks
ChadFarrow Jun 22, 2026
2e1b451
Strip image metadata (EXIF/GPS) before Blossom upload
ChadFarrow Jun 22, 2026
4092bda
Polish onboarding gate, artwork preview, and Primal connect step
ChadFarrow Jun 22, 2026
0c34329
Refine sign-in/upload copy and post-publish screen
ChadFarrow Jun 22, 2026
a5ebbef
Use musicsideproject.com as the canonical domain for all hosted feeds
ChadFarrow Jun 22, 2026
88cb5ce
Fix build: type metadata-strip Uint8Arrays as Uint8Array<ArrayBuffer>
ChadFarrow Jun 22, 2026
5febb3c
Update CLAUDE.md: canonical domain, inline XML, tsc -b verification
ChadFarrow Jun 22, 2026
ad2e5eb
Add returning-artist Profile page and refine Sign In flow
ChadFarrow Jun 25, 2026
2363155
Add onboarding landing-flow Phase 1 implementation plan
ChadFarrow Jun 25, 2026
c62d520
Add self-host vs MSP-host choice to onboarding gate
ChadFarrow Jun 25, 2026
436a821
Offer (not force) sign-in on the returning-artist gate branch
ChadFarrow Jun 25, 2026
d85c13f
Add explicit 'Skip — I'll host it myself' exit to the wizard
ChadFarrow Jun 25, 2026
12e592f
Address final-review nits: clarify self-host mode + mark dead tour steps
ChadFarrow Jun 25, 2026
b0d759e
Refine hosting copy and fix the self-host next-steps guide
ChadFarrow Jun 26, 2026
701810b
Move wizard skip button to bottom-right, relabel 'Skip to editor'
ChadFarrow Jun 26, 2026
74c04ce
Docs: document onboarding landing flow + Artist Profile in CLAUDE.md
ChadFarrow Jun 26, 2026
6d12ca7
Remove wizard footer skip button; rely on the top-right X to exit
ChadFarrow Jun 26, 2026
298c1e7
Hide 'Use my Nostr name & photo' for managed (Google) keys
ChadFarrow Jun 26, 2026
14f03a8
Hide per-person Nostr npub in wizard Credits step for managed keys
ChadFarrow Jun 26, 2026
d0268dc
Add 'use mine' npub button to wizard Credits for Nostr users
ChadFarrow Jun 26, 2026
1fc6f41
Add per-track audio preview to the wizard Review page
ChadFarrow Jun 26, 2026
b471ceb
Make the post-publish Podcast Index link its own standout CTA
ChadFarrow Jun 26, 2026
f36ca12
Docs: document managed-key UI hides, 'use mine' npub, audio preview, …
ChadFarrow Jun 26, 2026
c1848f5
Fix post-publish Podcast Index link (poll for numeric page; name-sear…
ChadFarrow Jun 26, 2026
a3eebbd
Order onboarding gate new-first (No on top, matching intro text)
ChadFarrow Jun 26, 2026
4d74558
Make the new-user gate button the purple primary, returning secondary
ChadFarrow Jun 26, 2026
7ec871c
Docs: document /api/pi-resolve + post-publish PI link polling
ChadFarrow Jun 26, 2026
99235ce
Add blossom.band + 24242.io media servers; always show host count
ChadFarrow Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 77 additions & 7 deletions CLAUDE.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions api/_utils/authUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { userBlobPath, userBlobPrefix, buildStoredKeyRecord } from './authUtils';

const GOOGLE_ID = '110248495921238986420';

describe('userBlobPath / userBlobPrefix', () => {
it('userBlobPath is the full .json object name used for put()', () => {
const path = userBlobPath(GOOGLE_ID);
expect(path.startsWith('auth/users/')).toBe(true);
expect(path.endsWith('.json')).toBe(true);
});

it('userBlobPrefix has no extension so it matches addRandomSuffix output on list()', () => {
const prefix = userBlobPrefix(GOOGLE_ID);
expect(prefix.startsWith('auth/users/')).toBe(true);
expect(prefix.endsWith('.json')).toBe(false);
});

it('the prefix is a prefix of the deterministic path', () => {
expect(userBlobPath(GOOGLE_ID).startsWith(userBlobPrefix(GOOGLE_ID))).toBe(true);
});

it('the prefix matches a random-suffixed blob name (the list() lookup invariant)', () => {
// Vercel Blob addRandomSuffix inserts `-<random>` before the extension.
const prefix = userBlobPrefix(GOOGLE_ID);
const suffixed = `${prefix}-aB3xZ9.json`;
expect(suffixed.startsWith(prefix)).toBe(true);
});

it('is deterministic for the same id and distinct across ids', () => {
expect(userBlobPrefix(GOOGLE_ID)).toBe(userBlobPrefix(GOOGLE_ID));
expect(userBlobPrefix(GOOGLE_ID)).not.toBe(userBlobPrefix('999999999999999999999'));
});
});

describe('buildStoredKeyRecord', () => {
it('stores only non-PII key material', () => {
const record = buildStoredKeyRecord('pubkeyhex', 'encryptednsec', '2026-06-19T00:00:00.000Z');
expect(record).toEqual({
pubkey: 'pubkeyhex',
encryptedNsec: 'encryptednsec',
createdAt: '2026-06-19T00:00:00.000Z',
});
});

it('never includes PII fields (email / displayName / picture)', () => {
const record = buildStoredKeyRecord('pubkeyhex', 'encryptednsec', '2026-06-19T00:00:00.000Z') as Record<string, unknown>;
expect(Object.keys(record)).not.toContain('email');
expect(Object.keys(record)).not.toContain('displayName');
expect(Object.keys(record)).not.toContain('picture');
});
});
136 changes: 136 additions & 0 deletions api/_utils/authUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
import type { VercelRequest } from '@vercel/node';

async function deriveEncryptionKey(userId: string): Promise<CryptoKey> {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error('AUTH_SECRET is not configured');
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(secret), 'HKDF', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: enc.encode(userId),
info: enc.encode('msp-managed-keypair-v1'),
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}

export async function encryptNsec(sk: Uint8Array, userId: string): Promise<string> {
const key = await deriveEncryptionKey(userId);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, sk);
const combined = new Uint8Array(12 + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), 12);
return Buffer.from(combined).toString('base64');
}

export async function decryptNsec(encrypted: string, userId: string): Promise<Uint8Array> {
const key = await deriveEncryptionKey(userId);
const combined = Buffer.from(encrypted, 'base64');
const iv = combined.subarray(0, 12);
const ciphertext = combined.subarray(12);
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new Uint8Array(plaintext);
}

// Unguessable prefix used to list() a user's keypair blob. Has no `.json`
// extension so it still matches the name produced by put(addRandomSuffix: true),
// which inserts `-<random>` before the extension.
export function userBlobPrefix(googleId: string): string {
const hash = bytesToHex(sha256(new TextEncoder().encode(googleId)));
return `auth/users/${hash}`;
}

// Deterministic object name passed to put(). With addRandomSuffix the stored
// name becomes `${userBlobPath}` with `-<random>` inserted before `.json`.
export function userBlobPath(googleId: string): string {
return `${userBlobPrefix(googleId)}.json`;
}

// The record persisted to Blob. Intentionally excludes PII (email / displayName
// / picture): those are carried in the signed session JWT and re-fetched fresh
// from Google on each login, so they must never sit in a public blob.
export interface StoredKeyRecord {
pubkey: string;
encryptedNsec: string;
createdAt: string;
}

export function buildStoredKeyRecord(
pubkey: string,
encryptedNsec: string,
createdAt: string
): StoredKeyRecord {
return { pubkey, encryptedNsec, createdAt };
}

async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
}

export async function signJwt(payload: Record<string, unknown>, expiresInSeconds = 30 * 86400): Promise<string> {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error('AUTH_SECRET is not configured');
const now = Math.floor(Date.now() / 1000);
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSeconds })).toString('base64url');
const signingInput = `${header}.${body}`;
const key = await hmacKey(secret);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput));
return `${signingInput}.${Buffer.from(sig).toString('base64url')}`;
}

export async function verifyJwt(token: string): Promise<Record<string, unknown>> {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error('AUTH_SECRET is not configured');
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid JWT format');
const [header, body, sig] = parts;
const signingInput = `${header}.${body}`;
const key = await hmacKey(secret);
const valid = await crypto.subtle.verify(
'HMAC', key,
Buffer.from(sig, 'base64url'),
new TextEncoder().encode(signingInput)
);
if (!valid) throw new Error('Invalid JWT signature');
const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as Record<string, unknown>;
const exp = typeof payload.exp === 'number' ? payload.exp : 0;
if (exp < Math.floor(Date.now() / 1000)) throw new Error('JWT expired');
return payload;
}

export function getSessionToken(req: VercelRequest): string | null {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return null;
const cookies: Record<string, string> = {};
for (const part of cookieHeader.split(';')) {
const eqIdx = part.indexOf('=');
if (eqIdx < 0) continue;
cookies[part.slice(0, eqIdx).trim()] = part.slice(eqIdx + 1).trim();
}
return cookies['msp-session'] ?? null;
}

export function sessionCookie(jwt: string, maxAgeSeconds = 30 * 86400): string {
return `msp-session=${jwt}; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAgeSeconds}; Path=/`;
}

export function clearSessionCookie(): string {
return 'msp-session=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/';
}
17 changes: 6 additions & 11 deletions api/_utils/feedUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// Shared API utilities for hosted feed endpoints
import type { VercelRequest } from '@vercel/node';
import { createHash } from 'crypto';
import { getAuthHeaders } from './podcastIndex.js';

Expand Down Expand Up @@ -172,16 +171,12 @@ export async function lookupPodcastIndexId(podcastGuid: string): Promise<number
* Get base URL from request headers
* Falls back to canonical URL for localhost (PI can't reach local dev servers)
*/
export function getBaseUrl(req: VercelRequest): string {
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';

// Use canonical URL for localhost since PI can't reach local dev servers
if (host.startsWith('localhost') || host.startsWith('127.0.0.1')) {
return process.env.CANONICAL_URL || 'https://msp.podtards.com';
}

const proto = req.headers['x-forwarded-proto'] || 'https';
return `${proto}://${host}`;
export function getBaseUrl(): string {
// Always use the canonical domain for hosted feed URLs so every feed (album,
// video, publisher) is stable regardless of which alias/preview host served the
// request. msp.podtards.com is a legacy alias and must never appear in newly
// generated feed URLs.
return (process.env.CANONICAL_URL || 'https://musicsideproject.com').replace(/\/$/, '');
}

/**
Expand Down
119 changes: 119 additions & 0 deletions api/auth/google-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
import { list, put } from '@vercel/blob';
import { encryptNsec, decryptNsec, userBlobPath, userBlobPrefix, buildStoredKeyRecord, signJwt, sessionCookie } from '../_utils/authUtils.js';

interface GoogleTokenPayload {
sub: string;
email: string;
name?: string;
picture?: string;
}

// Only the non-PII fields are read back; PII lives in the session JWT, not the blob.
interface StoredKeyData {
pubkey: string;
encryptedNsec: string;
}

async function exchangeCodeForUser(
code: string,
redirectUri: string
): Promise<GoogleTokenPayload> {
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Google token exchange failed: ${text}`);
}
const data = await res.json() as { id_token?: string };
if (!data.id_token) throw new Error('No id_token returned by Google');
// Decode payload (trusted — received directly from Google's token endpoint over HTTPS)
const [, payloadB64] = data.id_token.split('.');
return JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) as GoogleTokenPayload;
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
const { code, state, error } = req.query as Record<string, string>;

if (error) {
return res.redirect(302, `/?auth_error=${encodeURIComponent(error)}`);
}
if (!code || !state) {
return res.redirect(302, '/?auth_error=missing_params');
}

// Validate CSRF state from cookie
const cookieHeader = req.headers.cookie ?? '';
const cookies: Record<string, string> = {};
for (const part of cookieHeader.split(';')) {
const eqIdx = part.indexOf('=');
if (eqIdx < 0) continue;
cookies[part.slice(0, eqIdx).trim()] = part.slice(eqIdx + 1).trim();
}
if (!cookies['msp-oauth-state'] || cookies['msp-oauth-state'] !== state) {
return res.redirect(302, '/?auth_error=csrf_mismatch');
}

try {
const proto = (req.headers['x-forwarded-proto'] as string) ?? 'https';
const host = req.headers['host'] as string;
const redirectUri = `${proto}://${host}/api/auth/google-callback`;

const { sub: googleId, email, name: displayName, picture } = await exchangeCodeForUser(code, redirectUri);

const blobPrefix = userBlobPrefix(googleId);
let pubkey: string;
let sk: Uint8Array | null = null;

const { blobs } = await list({ prefix: blobPrefix });

if (blobs.length > 0) {
const blobRes = await fetch(blobs[0].url);
const stored = await blobRes.json() as StoredKeyData;
pubkey = stored.pubkey;
sk = await decryptNsec(stored.encryptedNsec, googleId);
} else {
sk = generateSecretKey();
pubkey = getPublicKey(sk);
const encryptedNsec = await encryptNsec(sk, googleId);
const data = buildStoredKeyRecord(pubkey, encryptedNsec, new Date().toISOString());
// Vercel Blob v2 only serves public blobs; addRandomSuffix gives the object
// an unguessable name so the keypair can't be fetched from a derived URL.
await put(userBlobPath(googleId), JSON.stringify(data), {
access: 'public',
contentType: 'application/json',
addRandomSuffix: true,
});
}

const npub = npubEncode(pubkey);
const jwt = await signJwt({
sub: googleId,
email,
pubkey,
npub,
displayName: displayName ?? email,
picture: picture ?? null,
});

res.setHeader('Set-Cookie', [
sessionCookie(jwt),
'msp-oauth-state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/',
]);
res.redirect(302, '/?auth=success');
} catch (err) {
console.error('[auth/google-callback]', err);
res.redirect(302, '/?auth_error=server_error');
}
}
25 changes: 25 additions & 0 deletions api/auth/google-start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';

export default async function handler(req: VercelRequest, res: VercelResponse) {
const clientId = process.env.GOOGLE_CLIENT_ID;
if (!clientId) {
return res.status(503).send('Google sign-in is not configured');
}

const state = crypto.randomUUID();
const proto = (req.headers['x-forwarded-proto'] as string) ?? 'https';
const host = req.headers['host'] as string;
const redirectUri = `${proto}://${host}/api/auth/google-callback`;

const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile',
state,
prompt: 'select_account',
});

res.setHeader('Set-Cookie', `msp-oauth-state=${state}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`);
res.redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params}`);
}
Loading