Skip to content

Commit 706631a

Browse files
dylanjeffersclaude
andcommitted
feat(migrate-tool): add track migration tool
A small Vite + React SPA backed by Vercel functions + Supabase that lets an artist request migration of tracks from an old Audius account they've lost access to onto a new account they control. Every migration sits in a pending queue until an Audius team member approves it via an admin route gated by a bearer token — the README spells out that identity verification of the requester has to happen out-of-band. The frontend uses the SDK's PKCE OAuth flow with apiKey only; on approval the backend re-uploads each track via createSdkWithServices using the dev app's API key + bearer token, acting on behalf of the new owner. Designed for migrate.audius.co. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bb5bdee commit 706631a

33 files changed

Lines changed: 3936 additions & 464 deletions

package-lock.json

Lines changed: 2116 additions & 464 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/migrate-tool/.env.example

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# ----- Frontend (Vite — must be prefixed VITE_) -----
2+
3+
# Audius developer app API key (safe to expose in browser).
4+
# Get one at audius.co/settings → Developer Apps.
5+
VITE_AUDIUS_API_KEY=
6+
7+
# "production" (default) or "development" to point at a local protocol stack.
8+
VITE_AUDIUS_ENVIRONMENT=production
9+
10+
# ----- Backend (Vercel functions — never expose in browser) -----
11+
12+
# Audius bearer token. Backend only. Used with the API key above so the
13+
# server can act on behalf of users who have authorized the developer app.
14+
AUDIUS_API_KEY=
15+
AUDIUS_BEARER_TOKEN=
16+
17+
# Bearer secret that admin endpoints (list/approve/reject) require in the
18+
# Authorization header. Pick a long random string and share it only with
19+
# Audius team members authorized to approve migrations.
20+
ADMIN_BEARER_TOKEN=
21+
22+
# Supabase connection. The service role key bypasses RLS and must stay
23+
# backend-only — never expose it.
24+
SUPABASE_URL=
25+
SUPABASE_SERVICE_ROLE_KEY=

packages/migrate-tool/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
dist
3+
.vercel
4+
.env
5+
.env.local
6+
*.log
7+
.turbo

packages/migrate-tool/README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# @audius/migrate-tool
2+
3+
A small web tool for moving an artist's tracks from an old Audius account to a
4+
new one — for cases where the artist has lost access to their original account
5+
(forgotten password, lost email) and has created a new account.
6+
7+
Designed to be deployed to **migrate.audius.co** on Vercel with a Supabase
8+
database backing the request queue.
9+
10+
## How it works
11+
12+
1. The artist signs in with their new Audius account (OAuth via the Audius
13+
developer app).
14+
2. They enter the handle of their old account. The tool previews the tracks
15+
that would be migrated.
16+
3. They submit a migration request. The request is stored in Supabase with
17+
`status = 'pending'`.
18+
4. An Audius team member opens `/admin`, unlocks with the admin bearer token,
19+
and reviews the request. Identity verification (confirming the requester
20+
actually owns the old account) happens **out-of-band** via the usual
21+
support channel — the tool does not enforce it.
22+
5. On approval the backend pulls each old track's audio + artwork via the SDK
23+
and re-uploads it on the new account using the developer app's bearer
24+
token. Per-track results are written back to the DB and shown on the
25+
status page.
26+
27+
## Limitations
28+
29+
- **Original masters**: only tracks the artist marked as **downloadable** expose
30+
the original audio file via the public API. Other tracks migrate with the
31+
transcoded MP3 stream, which is a lossy re-encoding rather than a bit-for-bit
32+
copy. The track preview shows which of these applies per track.
33+
- **No identity verification in-tool**: anyone signed in can request migration
34+
of any handle. The approver is responsible for verifying the requester owns
35+
the old account before approving. Don't approve a request without
36+
confirming identity through a separate channel.
37+
- **Old account is not modified**: the tracks are re-created on the new
38+
account. The originals on the old account are untouched (the tool has no
39+
authority over the old account).
40+
- **No social-graph preservation**: plays, favorites, reposts, and comments
41+
on the old tracks do not carry over.
42+
43+
## Deploy
44+
45+
### 1. Supabase
46+
47+
Create a project, then run the SQL in `supabase/migrations/0001_init.sql`.
48+
49+
You'll need:
50+
51+
- `SUPABASE_URL` — project URL
52+
- `SUPABASE_SERVICE_ROLE_KEY` — backend-only key (do not expose to the browser)
53+
54+
### 2. Audius developer app
55+
56+
Create a developer app at <https://audius.co/settings> → Developer Apps. You'll
57+
get an **API Key** and a **Bearer Token**.
58+
59+
- `VITE_AUDIUS_API_KEY` — the API key (safe in the browser; baked into the build)
60+
- `AUDIUS_API_KEY` — same API key, for the backend
61+
- `AUDIUS_BEARER_TOKEN` — backend-only; grants the app permission to act on
62+
behalf of users who have authorized it via OAuth
63+
64+
You'll also need to whitelist the deployment's OAuth redirect URI in the dev
65+
app's settings (e.g. `https://migrate.audius.co/`).
66+
67+
### 3. Admin token
68+
69+
- `ADMIN_BEARER_TOKEN` — pick a long random string. Share it only with team
70+
members authorized to approve migrations.
71+
72+
### 4. Vercel
73+
74+
```sh
75+
cd packages/migrate-tool
76+
npx vercel link
77+
npx vercel env add VITE_AUDIUS_API_KEY
78+
npx vercel env add AUDIUS_API_KEY
79+
npx vercel env add AUDIUS_BEARER_TOKEN
80+
npx vercel env add ADMIN_BEARER_TOKEN
81+
npx vercel env add SUPABASE_URL
82+
npx vercel env add SUPABASE_SERVICE_ROLE_KEY
83+
npx vercel --prod
84+
```
85+
86+
Then add `migrate.audius.co` as a domain in the Vercel project.
87+
88+
## Local development
89+
90+
```sh
91+
cp .env.example .env.local
92+
# Fill in the values, then:
93+
npm install
94+
npm run dev
95+
```
96+
97+
The Vite dev server runs at `http://localhost:5180`. To exercise the API
98+
functions locally, run them with `npx vercel dev` instead.
99+
100+
## Approving a request
101+
102+
1. Go to `https://migrate.audius.co/admin`.
103+
2. Paste the `ADMIN_BEARER_TOKEN` to unlock.
104+
3. Review the request — especially the old handle and the track list.
105+
4. **Verify the requester owns the old account through your usual support
106+
channel.** This is the only safeguard against migration abuse.
107+
5. Click **Approve & execute**. The backend runs the migration synchronously
108+
(Vercel function timeout is set to 5 minutes in `vercel.json`).
109+
110+
## Files
111+
112+
- `src/` — Vite + React SPA (home / status / admin pages)
113+
- `api/` — Vercel serverless functions
114+
- `api/requests/index.ts``POST /api/requests` (create)
115+
- `api/requests/[id].ts``GET /api/requests/:id` (status)
116+
- `api/admin/requests.ts``GET /api/admin/requests` (list, bearer-gated)
117+
- `api/admin/approve.ts``POST /api/admin/approve?id=…` (bearer-gated)
118+
- `api/admin/reject.ts``POST /api/admin/reject?id=…` (bearer-gated)
119+
- `api/_lib/migrate.ts` — migration worker
120+
- `supabase/migrations/0001_init.sql` — DB schema
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
createSdkWithServices,
3+
type AudiusSdkWithServices
4+
} from '@audius/sdk'
5+
6+
let serverSdk: AudiusSdkWithServices | null = null
7+
8+
/**
9+
* Server-side SDK initialized with the developer app's API key + bearer
10+
* token. The bearer token grants the app permission to act on behalf of
11+
* any user who has authorized it via OAuth.
12+
*
13+
* Per the SDK README: "Bearer Token — backend only. Grants your app the
14+
* ability to act on behalf of users who have authorized it. Never expose
15+
* this in browser or mobile code."
16+
*
17+
* We use createSdkWithServices (rather than the public sdk() factory) so
18+
* sdk.tracks is the wrapped TracksApi with friendly helpers like
19+
* getTrackStreamUrl, getTrackDownloadUrl, and the createTrack overload
20+
* that handles audio + image upload + trackCid in one call.
21+
*/
22+
export function getServerSDK(): AudiusSdkWithServices {
23+
if (serverSdk) return serverSdk
24+
const apiKey = process.env.AUDIUS_API_KEY
25+
const bearerToken = process.env.AUDIUS_BEARER_TOKEN
26+
if (!apiKey || !bearerToken) {
27+
throw new Error(
28+
'AUDIUS_API_KEY and AUDIUS_BEARER_TOKEN must be set on the server.'
29+
)
30+
}
31+
serverSdk = createSdkWithServices({
32+
apiKey,
33+
bearerToken,
34+
appName: 'AudiusTrackMigration'
35+
})
36+
return serverSdk
37+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { VercelRequest, VercelResponse } from '@vercel/node'
2+
3+
/**
4+
* Constant-time check that the incoming Authorization header matches the
5+
* admin bearer token. Returns true if authorized, otherwise writes a 401
6+
* response and returns false — callers can early-return.
7+
*/
8+
export function requireAdmin(
9+
req: VercelRequest,
10+
res: VercelResponse
11+
): boolean {
12+
const expected = process.env.ADMIN_BEARER_TOKEN
13+
if (!expected) {
14+
res.status(500).json({ error: 'ADMIN_BEARER_TOKEN not configured.' })
15+
return false
16+
}
17+
const header = req.headers.authorization ?? ''
18+
const match = /^Bearer\s+(.+)$/.exec(header)
19+
const token = match?.[1] ?? ''
20+
if (!constantTimeEquals(token, expected)) {
21+
res.status(401).json({ error: 'Unauthorized.' })
22+
return false
23+
}
24+
return true
25+
}
26+
27+
function constantTimeEquals(a: string, b: string): boolean {
28+
if (a.length !== b.length) return false
29+
let mismatch = 0
30+
for (let i = 0; i < a.length; i++) {
31+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i)
32+
}
33+
return mismatch === 0
34+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { Genre, Mood } from '@audius/sdk'
2+
3+
import { getServerSDK } from './audius'
4+
import { getSupabase, TABLE } from './supabase'
5+
import type { DbRow, TrackResult } from './types'
6+
7+
/**
8+
* Fetch a URL into a Blob with a reasonable timeout. Used for pulling the
9+
* old track's audio and artwork before re-uploading them to the new owner.
10+
*/
11+
async function fetchBlob(url: string, timeoutMs = 90_000): Promise<Blob> {
12+
const controller = new AbortController()
13+
const timer = setTimeout(() => controller.abort(), timeoutMs)
14+
try {
15+
const res = await fetch(url, { signal: controller.signal })
16+
if (!res.ok) throw new Error(`Fetch ${res.status} ${res.statusText} (${url})`)
17+
return await res.blob()
18+
} finally {
19+
clearTimeout(timer)
20+
}
21+
}
22+
23+
function filenameFromUrl(url: string, fallback: string): string {
24+
try {
25+
const u = new URL(url)
26+
const last = u.pathname.split('/').filter(Boolean).pop()
27+
return last || fallback
28+
} catch {
29+
return fallback
30+
}
31+
}
32+
33+
function blobToFile(blob: Blob, name: string): File {
34+
return new File([blob], name, { type: blob.type || 'application/octet-stream' })
35+
}
36+
37+
/**
38+
* Run the migration for one DB row. Updates the row in-place with per-track
39+
* results as it goes, and sets the final status when done.
40+
*
41+
* Limitation: only tracks flagged as downloadable expose the original
42+
* audio file. Other tracks fall back to the transcoded mp3 stream, which
43+
* is a lossy re-encoding rather than a bit-for-bit copy.
44+
*/
45+
export async function executeMigration(row: DbRow): Promise<void> {
46+
const supabase = getSupabase()
47+
const sdk = getServerSDK()
48+
49+
await supabase
50+
.from(TABLE)
51+
.update({ status: 'running' })
52+
.eq('id', row.id)
53+
54+
const results: TrackResult[] = row.tracks.map((t) => ({
55+
oldTrackId: t.trackId,
56+
status: 'pending'
57+
}))
58+
59+
const persistResults = async () => {
60+
await supabase
61+
.from(TABLE)
62+
.update({ results })
63+
.eq('id', row.id)
64+
}
65+
66+
let anyFailed = false
67+
68+
for (let i = 0; i < row.tracks.length; i++) {
69+
const preview = row.tracks[i]!
70+
try {
71+
const trackRes = await sdk.tracks.getTrack({ trackId: preview.trackId })
72+
const track = trackRes.data
73+
if (!track) throw new Error('Track not found on source account.')
74+
75+
const audioUrl = preview.isDownloadable
76+
? await sdk.tracks.getTrackDownloadUrl({ trackId: preview.trackId })
77+
: await sdk.tracks.getTrackStreamUrl({ trackId: preview.trackId })
78+
79+
const audioBlob = await fetchBlob(audioUrl)
80+
const audioFile = blobToFile(
81+
audioBlob,
82+
filenameFromUrl(audioUrl, `${preview.trackId}.mp3`)
83+
)
84+
85+
let imageFile: File | undefined
86+
const artworkUrl =
87+
track.artwork?._1000x1000 ??
88+
track.artwork?._480x480 ??
89+
track.artwork?._150x150
90+
if (artworkUrl) {
91+
const imageBlob = await fetchBlob(artworkUrl)
92+
imageFile = blobToFile(
93+
imageBlob,
94+
filenameFromUrl(artworkUrl, 'artwork.jpg')
95+
)
96+
}
97+
98+
const upload = await sdk.tracks.createTrack({
99+
userId: row.new_user_id,
100+
audioFile,
101+
imageFile,
102+
// The generated type requires trackCid here, but the wrapped
103+
// createTrack populates it from the audio upload response. See
104+
// TracksApi.createTrack → populateTrackMetadataWithUploadResponseV2.
105+
// @ts-expect-error trackCid is set by the SDK after audio upload
106+
metadata: {
107+
title: track.title,
108+
genre: track.genre as Genre,
109+
description: track.description ?? undefined,
110+
mood: (track.mood as Mood | undefined) ?? undefined,
111+
tags: track.tags ?? undefined,
112+
isrc: track.isrc ?? undefined,
113+
iswc: track.iswc ?? undefined,
114+
license: track.license ?? undefined
115+
}
116+
})
117+
118+
results[i] = {
119+
oldTrackId: preview.trackId,
120+
newTrackId: upload.trackId,
121+
status: 'success'
122+
}
123+
} catch (e) {
124+
anyFailed = true
125+
results[i] = {
126+
oldTrackId: preview.trackId,
127+
status: 'failed',
128+
error: e instanceof Error ? e.message : String(e)
129+
}
130+
}
131+
await persistResults()
132+
}
133+
134+
await supabase
135+
.from(TABLE)
136+
.update({
137+
status: anyFailed ? 'failed' : 'completed',
138+
results,
139+
completed_at: new Date().toISOString(),
140+
failure_reason: anyFailed
141+
? 'One or more tracks failed to migrate. See per-track results.'
142+
: null
143+
})
144+
.eq('id', row.id)
145+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { DbRow } from './types'
2+
3+
export function rowToResponse(row: DbRow) {
4+
return {
5+
id: row.id,
6+
newUserId: row.new_user_id,
7+
newUserHandle: row.new_user_handle,
8+
oldHandle: row.old_handle,
9+
status: row.status,
10+
tracks: row.tracks,
11+
results: row.results ?? [],
12+
rejectionReason: row.rejection_reason,
13+
failureReason: row.failure_reason,
14+
createdAt: row.created_at,
15+
approvedAt: row.approved_at,
16+
completedAt: row.completed_at
17+
}
18+
}

0 commit comments

Comments
 (0)