From 259c02ba42e896e09835d86d68b043e29ddd66e6 Mon Sep 17 00:00:00 2001 From: Marcus Pasell Date: Sat, 8 Aug 2026 03:04:25 -0700 Subject: [PATCH] feat(sdk)!: attribute audio uploads and previews to a required userId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio uploads and preview generation now carry the id of the user they are made for — as plain metadata, not a signature. Validator nodes attest the resulting cids to that user on chain (OpenAudio/go-openaudio feat/content-auth-unsigned), which is what makes them claimable on a track once content authorization is enforced; generate_preview refuses users that do not already claim the source cid. No client-side signing is involved, deliberately. The id is an assertion; ownership is enforced where it always was, in the signed entity-manager write that names a cid on a track. That keeps every SDK flow working — including OAuth apps with no client-side wallet — with nothing but this parameter. BREAKING: userId is now required on tracks.uploadTrackFiles and uploads.createAudioUpload, and Storage.generatePreview requires a decoded userId. The high-level methods (createTrack, updateTrack, uploadTrack, publishTrack) already required userId and now thread it through. Required rather than optional so an integrator who upgrades cannot silently produce unclaimable uploads that fail later at publish; explicit rather than derived from auth state because a manager or developer-app session can act for more than one user. Clients updated to satisfy the requirement: useUpload passes the current account's id on track, stem, cover-art and collection-artwork uploads (with the id in every dependency array that reads it), the web stems saga passes the id it already looked up, and all four upload examples and their READMEs pass userId. Docs-site pages for the Uploads and Tracks APIs document the new parameter. Supersedes #14550, #14552 and #14554 (the EIP-712 signature approach). Co-Authored-By: Claude Fable 5 --- .changeset/sdk-upload-user-attribution.md | 9 ++++ .../src/api/tan-query/upload/useUpload.ts | 51 ++++++++++++++---- .../developers/guides/gate-release-access.mdx | 2 +- packages/docs/docs/pages/sdk/tracks.mdx | 5 +- packages/docs/docs/pages/sdk/uploads.mdx | 11 +++- packages/mobile/examples/upload/App.tsx | 3 +- packages/sdk/src/sdk/api/tracks/TracksApi.ts | 11 ++-- packages/sdk/src/sdk/api/tracks/types.ts | 7 +++ .../sdk/src/sdk/api/uploads/UploadsApi.ts | 15 ++++++ .../src/sdk/services/Storage/Storage.test.ts | 52 +++++++++++++++++++ .../sdk/src/sdk/services/Storage/Storage.ts | 25 ++++++--- .../sdk/src/sdk/services/Storage/types.ts | 16 +++++- .../web/examples/gated-upload/src/App.tsx | 3 +- packages/web/examples/upload-server/README.md | 2 +- .../web/examples/upload-server/src/App.tsx | 3 +- packages/web/examples/upload/README.md | 2 +- packages/web/examples/upload/src/App.tsx | 5 +- .../store/application/ui/stemsUpload/sagas.ts | 4 +- 18 files changed, 195 insertions(+), 31 deletions(-) create mode 100644 .changeset/sdk-upload-user-attribution.md create mode 100644 packages/sdk/src/sdk/services/Storage/Storage.test.ts diff --git a/.changeset/sdk-upload-user-attribution.md b/.changeset/sdk-upload-user-attribution.md new file mode 100644 index 00000000000..633d8474e95 --- /dev/null +++ b/.changeset/sdk-upload-user-attribution.md @@ -0,0 +1,9 @@ +--- +'@audius/sdk': major +--- + +Audio uploads and preview generation now carry the id of the user they are made for. Validator nodes attest the resulting cids to that user on chain, which is what makes them claimable on a track once content authorization is enforced; `generate_preview` additionally refuses users that do not already claim the source audio. + +BREAKING: `userId` (encoded id) is now required on `tracks.uploadTrackFiles` and `uploads.createAudioUpload`, and `Storage.generatePreview` requires a decoded `userId`. The high-level methods (`createTrack`, `updateTrack`, `uploadTrack`, `publishTrack`) already required `userId` and now thread it through automatically — callers of those need no changes. The id is always explicit, never derived from auth state, because a manager or developer-app session can act for more than one user. + +No signature or wallet is involved: the id is an assertion, and ownership is enforced where it always was — in the signed entity-manager write that names a cid on a track. diff --git a/packages/common/src/api/tan-query/upload/useUpload.ts b/packages/common/src/api/tan-query/upload/useUpload.ts index 54dcaf50307..1163fcf1925 100644 --- a/packages/common/src/api/tan-query/upload/useUpload.ts +++ b/packages/common/src/api/tan-query/upload/useUpload.ts @@ -1,6 +1,6 @@ import { useRef, useCallback } from 'react' -import { HashId, type UploadTrackFilesTask } from '@audius/sdk' +import { HashId, Id, type UploadTrackFilesTask } from '@audius/sdk' import { useDispatch } from 'react-redux' import { fileToSdk } from '~/adapters' @@ -15,6 +15,7 @@ import { UploadType } from '~/store' +import { useCurrentAccount } from '../users/account/useCurrentAccount' import { type QueryContextType, useQueryContext } from '../utils' import { usePublishCollection } from './usePublishCollection' @@ -31,7 +32,8 @@ const { const getStemUploadTasks = async ( context: Pick, - tracks: TrackForUpload[] + tracks: TrackForUpload[], + userId: number ) => { const sdk = await context.audiusSdk() return tracks.flatMap( @@ -40,6 +42,7 @@ const getStemUploadTasks = async ( const file = (stemFile as StemUploadWithFile).file const task = sdk.tracks.uploadTrackFiles({ audioFile: fileToSdk(file, 'audio'), + userId: Id.parse(userId), onProgress: (_, { key, loaded, total, transcode }) => { context.dispatch( updateProgress({ @@ -70,7 +73,8 @@ const getStemUploadTasks = async ( const getCoverArtUploadTasks = async ( context: Pick, - tracks: TrackForUpload[] + tracks: TrackForUpload[], + userId: number ) => { const sdk = await context.audiusSdk() return tracks @@ -91,6 +95,7 @@ const getCoverArtUploadTasks = async ( const file = fileToSdk(t.metadata.artwork.file, 'cover_art') const task = sdk.tracks.uploadTrackFiles({ imageFile: file, + userId: Id.parse(userId), onProgress: (_, { key, loaded, total }) => { context.dispatch( uploadActions.updateProgress({ @@ -120,12 +125,14 @@ const getCoverArtUploadTasks = async ( const getTrackUploadTasks = async ( context: Pick, - tracks: TrackForUpload[] + tracks: TrackForUpload[], + userId: number ) => { const sdk = await context.audiusSdk() return tracks.map((t) => { const task = sdk.tracks.uploadTrackFiles({ audioFile: fileToSdk(t.file, 'audio'), + userId: Id.parse(userId), onProgress: (_, { key, loaded, total, transcode }) => { context.dispatch( uploadActions.updateProgress({ @@ -162,6 +169,18 @@ export const useUpload = ( audiusSdk, analytics: { make, track } } = useQueryContext() + // Uploads carry this user's id so the validator can attest on chain who the + // bytes were uploaded for, which is what makes the resulting cids claimable + // on a track. The SDK requires it, so a missing account fails loudly here + // rather than producing an unclaimable upload. + const { data: account } = useCurrentAccount() + const userId = account?.userId ?? undefined + const requireUserId = useCallback(() => { + if (userId === undefined) { + throw new Error('useUpload: no current user id — cannot upload') + } + return userId + }, [userId]) const { mutateAsync: publishTracksAsync } = usePublishTracks() const { mutateAsync: publishCollectionAsync } = usePublishCollection() @@ -208,7 +227,11 @@ export const useUpload = ( ) }) - const tasks = await getTrackUploadTasks({ audiusSdk, dispatch }, tracks) + const tasks = await getTrackUploadTasks( + { audiusSdk, dispatch }, + tracks, + requireUserId() + ) // Store the upload tasks for potential aborting later tasks.forEach((task, i) => { @@ -217,7 +240,7 @@ export const useUpload = ( return await uploadFiles(tasks) }, - [audiusSdk, dispatch, uploadFiles, track, make] + [audiusSdk, dispatch, uploadFiles, track, make, requireUserId] ) /** @@ -265,11 +288,12 @@ export const useUpload = ( async (tracks: TrackForUpload[]) => { const tasks = await getCoverArtUploadTasks( { audiusSdk, dispatch }, - tracks + tracks, + requireUserId() ) return await uploadFiles(tasks) }, - [audiusSdk, dispatch, uploadFiles] + [audiusSdk, dispatch, uploadFiles, requireUserId] ) const uploadCollectionArtwork = useCallback( @@ -295,6 +319,7 @@ export const useUpload = ( const uploadTask = sdk.tracks.uploadTrackFiles({ imageFile: fileToSdk(imageFile, 'artwork'), + userId: Id.parse(requireUserId()), onProgress: (_, { key, loaded, total }) => { dispatch( updateProgress({ @@ -319,15 +344,19 @@ export const useUpload = ( { ...uploadTask, clientId: 'collection-artwork', key: 'image' } ]) }, - [audiusSdk, dispatch, uploadFiles] + [audiusSdk, dispatch, uploadFiles, requireUserId] ) const uploadStemFiles = useCallback( async (tracks: TrackForUpload[]) => { - const tasks = await getStemUploadTasks({ audiusSdk, dispatch }, tracks) + const tasks = await getStemUploadTasks( + { audiusSdk, dispatch }, + tracks, + requireUserId() + ) return await uploadFiles(tasks) }, - [audiusSdk, dispatch, uploadFiles] + [audiusSdk, dispatch, uploadFiles, requireUserId] ) const startUpload = useCallback( diff --git a/packages/docs/docs/pages/developers/guides/gate-release-access.mdx b/packages/docs/docs/pages/developers/guides/gate-release-access.mdx index 81b2a72fa75..d08dcb02481 100644 --- a/packages/docs/docs/pages/developers/guides/gate-release-access.mdx +++ b/packages/docs/docs/pages/developers/guides/gate-release-access.mdx @@ -43,7 +43,7 @@ The [gated-upload example](https://github.com/AudiusProject/apps/tree/main/packa **Client** -- OAuth login, upload via SDK (`uploadTrackFiles`, then `create-track` with the server), and streaming via `GET /stream/:trackId` (server redirects to signed URL). +- OAuth login, upload via SDK (`uploadTrackFiles` with the uploading user's `userId`, then `create-track` with the server), and streaming via `GET /stream/:trackId` (server redirects to signed URL). Run the server from `packages/web/examples/gated-upload/server` with `AUDIUS_API_KEY`, `AUDIUS_BEARER_TOKEN`, and `SIGNER_PRIVATE_KEY` in `.env`. See the [README](https://github.com/AudiusProject/apps/blob/main/packages/web/examples/gated-upload/README.md) for full setup. diff --git a/packages/docs/docs/pages/sdk/tracks.mdx b/packages/docs/docs/pages/sdk/tracks.mdx index 5776678c608..1c390a28849 100644 --- a/packages/docs/docs/pages/sdk/tracks.mdx +++ b/packages/docs/docs/pages/sdk/tracks.mdx @@ -199,7 +199,9 @@ Example: ```ts import fs from 'fs' -// First, upload files using the Uploads API +// First, upload files using the Uploads API. +// Audio uploads name the user they are made for; the resulting CIDs are only +// claimable on that user's tracks. const trackBuffer = fs.readFileSync('path/to/track.mp3') const audioUpload = audiusSdk.uploads.createAudioUpload({ file: { @@ -207,6 +209,7 @@ const audioUpload = audiusSdk.uploads.createAudioUpload({ name: 'track.mp3', type: 'audio/mpeg', }, + userId: '7eP5n', }) const audioResult = await audioUpload.start() diff --git a/packages/docs/docs/pages/sdk/uploads.mdx b/packages/docs/docs/pages/sdk/uploads.mdx index 5af5fbe9a4e..add8fd1c553 100644 --- a/packages/docs/docs/pages/sdk/uploads.mdx +++ b/packages/docs/docs/pages/sdk/uploads.mdx @@ -22,6 +22,12 @@ Uploads API to get CIDs, then pass those CIDs as metadata when creating or updat Upload an audio file to a storage node. Returns the resulting CIDs and audio analysis metadata (duration, BPM, musical key). +The `userId` names the user the upload is made for. Validator nodes record it on chain against the +resulting CIDs, which is what allows those CIDs to later be named on that user's track. Uploads made +without the correct user id cannot be published once content authorization is enforced. The id is +always passed explicitly — it is never inferred from your API key or session, because a manager or +developer app can act on behalf of more than one user. + Example: ```ts @@ -35,6 +41,7 @@ const upload = audiusSdk.uploads.createAudioUpload({ name: 'track.mp3', type: 'audio/mpeg', }, + userId: '7eP5n', onProgress: ({ loaded, total }) => { console.log(`Upload progress: ${Math.round((loaded / total) * 100)}%`) }, @@ -52,6 +59,7 @@ Create an object with the following fields and pass it as the first argument. | Name | Type | Description | Required? | | :-------------------- | :---------------------------------------------------------------------- | :--------------------------------------------------- | :----------- | | `file` | `File` | The audio file to upload | **Required** | +| `userId` | `string` | The ID of the user the audio is uploaded for | **Required** | | `onProgress` | `(event: { loaded: number; total: number; transcode: number }) => void` | A callback for tracking upload progress | _Optional_ | | `previewStartSeconds` | `number` | Start time in seconds for generating a preview clip | _Optional_ | | `placementHosts` | `string[]` | A list of storage node hosts to prefer for placement | _Optional_ | @@ -126,7 +134,7 @@ a track with the returned CIDs. ```ts import fs from 'fs' -// Step 1: Upload the audio file +// Step 1: Upload the audio file for the user who will own the track const trackBuffer = fs.readFileSync('path/to/track.mp3') const audioUpload = audiusSdk.uploads.createAudioUpload({ file: { @@ -134,6 +142,7 @@ const audioUpload = audiusSdk.uploads.createAudioUpload({ name: 'track.mp3', type: 'audio/mpeg', }, + userId: '7eP5n', previewStartSeconds: 30, }) const audioResult = await audioUpload.start() diff --git a/packages/mobile/examples/upload/App.tsx b/packages/mobile/examples/upload/App.tsx index 726f1ce8785..11d31783ab4 100644 --- a/packages/mobile/examples/upload/App.tsx +++ b/packages/mobile/examples/upload/App.tsx @@ -182,7 +182,8 @@ export default function App() { uri: audioFile.uri, name: audioFile.name ?? 'audio', type: audioFile.mimeType ?? 'audio/mpeg' - } + }, + userId: String(profile.id ?? '') }) const imageUpload = coverUri diff --git a/packages/sdk/src/sdk/api/tracks/TracksApi.ts b/packages/sdk/src/sdk/api/tracks/TracksApi.ts index 2196d33e825..f11619a4ea2 100644 --- a/packages/sdk/src/sdk/api/tracks/TracksApi.ts +++ b/packages/sdk/src/sdk/api/tracks/TracksApi.ts @@ -178,7 +178,7 @@ export class TracksApi extends GeneratedTracksApi { let totalProgressPercentage = 0 return { start: async () => { - const { audioFile, imageFile, fileMetadata, onProgress } = + const { audioFile, imageFile, fileMetadata, userId, onProgress } = await parseParams('uploadTrackFiles', UploadTrackFilesSchema)(params) imageUpload = imageFile @@ -214,6 +214,7 @@ export class TracksApi extends GeneratedTracksApi { template: 'audio', filename: audioFile.name ?? undefined, filetype: audioFile.type ?? undefined, + userId, placementHosts: fileMetadata?.placementHosts, previewStartSeconds: fileMetadata?.previewStartSeconds } @@ -268,7 +269,8 @@ export class TracksApi extends GeneratedTracksApi { async () => await this.storage.generatePreview({ cid: populatedMetadata.trackCid!, - secondOffset: populatedMetadata.previewStartSeconds! + secondOffset: populatedMetadata.previewStartSeconds!, + userId }), (e) => { this.logger.info('Retrying generatePreview', e) @@ -396,6 +398,7 @@ export class TracksApi extends GeneratedTracksApi { await this.uploadTrackFiles({ audioFile: params.audioFile, imageFile: params.imageFile, + userId: params.userId, fileMetadata: { placementHosts: params.metadata.placementHosts, previewStartSeconds: params.metadata.previewStartSeconds @@ -480,6 +483,7 @@ export class TracksApi extends GeneratedTracksApi { await this.uploadTrackFiles({ audioFile: params.audioFile, imageFile: params.imageFile, + userId: params.userId, fileMetadata: { placementHosts: params.metadata.placementHosts, previewStartSeconds: params.metadata.previewStartSeconds @@ -509,7 +513,8 @@ export class TracksApi extends GeneratedTracksApi { async () => await this.storage.generatePreview({ cid: metadata.trackCid!, - secondOffset: metadata.previewStartSeconds! + secondOffset: metadata.previewStartSeconds!, + userId: decodeHashId(params.userId)! }), (e) => { this.logger.info('Retrying generatePreview', e) diff --git a/packages/sdk/src/sdk/api/tracks/types.ts b/packages/sdk/src/sdk/api/tracks/types.ts index bee2f5cce8d..d4110a59cbf 100644 --- a/packages/sdk/src/sdk/api/tracks/types.ts +++ b/packages/sdk/src/sdk/api/tracks/types.ts @@ -271,6 +271,13 @@ export type UploadTrackRequest = Omit< export const UploadTrackFilesSchema = z .object({ + // The user the upload is made for. Required — and always explicit, never + // derived from auth state, because a manager or developer-app session can + // act for more than one user. Audio uploads carry it so the validator can + // attest on chain that the bytes were uploaded for this user, which is + // what makes the resulting cids claimable on a track. Without a claim, + // publishing fails once content authorization is enforced. + userId: HashId, audioFile: z.optional(AudioFile), imageFile: z.optional(ImageFile), fileMetadata: z diff --git a/packages/sdk/src/sdk/api/uploads/UploadsApi.ts b/packages/sdk/src/sdk/api/uploads/UploadsApi.ts index fd659adef07..4950ac0604e 100644 --- a/packages/sdk/src/sdk/api/uploads/UploadsApi.ts +++ b/packages/sdk/src/sdk/api/uploads/UploadsApi.ts @@ -1,5 +1,6 @@ import { type ProgressHandler } from '../../services' import type { CrossPlatformFile } from '../../types/File' +import { decodeHashId } from '../../utils/hashId' import type { UploadsApiServicesConfig } from './types' @@ -14,21 +15,34 @@ export class UploadsApi { * Creates an audio file upload task that uploads to a validator and returns * the resulting CIDs and analysis metadata. * + * The user id names who the upload is made for; the validator attests the + * resulting cids to that user on chain, which is what lets them be named on + * a track. It is required — and always explicit, never derived from auth + * state, because a manager or developer-app session can act for more than + * one user. + * * Optionally accepts a callback for tracking upload progress, a list of * placement hosts to prefer for storage, and a start time in seconds * for generating a preview clip. */ public createAudioUpload({ file, + userId, onProgress, previewStartSeconds, placementHosts }: { file: CrossPlatformFile + /** The encoded id of the user the audio is uploaded for */ + userId: string onProgress?: ProgressHandler placementHosts?: string[] previewStartSeconds?: number }) { + const decodedUserId = decodeHashId(userId) + if (decodedUserId === null) { + throw new Error(`createAudioUpload: could not decode userId "${userId}"`) + } const upload = this.storage.uploadFile({ file, onProgress, @@ -36,6 +50,7 @@ export class UploadsApi { template: 'audio', filename: file.name ?? undefined, filetype: file.type ?? undefined, + userId: decodedUserId, previewStartSeconds, placementHosts: placementHosts?.join(',') } diff --git a/packages/sdk/src/sdk/services/Storage/Storage.test.ts b/packages/sdk/src/sdk/services/Storage/Storage.test.ts new file mode 100644 index 00000000000..3788eddc8f8 --- /dev/null +++ b/packages/sdk/src/sdk/services/Storage/Storage.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import fetch from '../../utils/fetch' +import type { StorageNodeSelectorService } from '../StorageNodeSelector' + +import { Storage } from './Storage' + +vi.mock('../../utils/fetch') + +const mockFetch = vi.mocked(fetch) + +const storageNodeSelector = { + getSelectedNode: async () => 'https://node.example.com' +} as unknown as StorageNodeSelectorService + +const previewResponse = (cid: string) => + ({ ok: true, json: async () => ({ cid }) }) as unknown as Response + +describe('generatePreview', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + // The user id names who the preview is attested to; the validator refuses + // users that do not claim the source cid. It travels as a query parameter — + // an assertion, not a credential. + it('sends the asserted user id', async () => { + mockFetch.mockResolvedValue(previewResponse('preview-cid')) + const storage = new Storage({ storageNodeSelector }) + + const result = await storage.generatePreview({ + cid: 'some-cid', + secondOffset: 30, + userId: 42 + }) + + expect(result).toBe('preview-cid') + const [url, init] = mockFetch.mock.calls[0]! as [URL, RequestInit] + expect(init.method).toBe('POST') + expect(url.pathname).toBe('/generate_preview/some-cid/30') + expect(url.searchParams.get('userId')).toBe('42') + }) + + it('throws on a non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 } as Response) + const storage = new Storage({ storageNodeSelector }) + + await expect( + storage.generatePreview({ cid: 'some-cid', secondOffset: 15, userId: 7 }) + ).rejects.toThrow('status: 401') + }) +}) diff --git a/packages/sdk/src/sdk/services/Storage/Storage.ts b/packages/sdk/src/sdk/services/Storage/Storage.ts index cdc6b4c8380..40e6271c1ee 100644 --- a/packages/sdk/src/sdk/services/Storage/Storage.ts +++ b/packages/sdk/src/sdk/services/Storage/Storage.ts @@ -85,6 +85,9 @@ export class Storage implements StorageService { file.type || 'application/octet-stream', template: metadata.template, + ...(metadata.userId !== undefined + ? { userId: metadata.userId.toString() } + : {}), ...(metadata.placementHosts ? { placementHosts: metadata.placementHosts } : {}), @@ -159,17 +162,27 @@ export class Storage implements StorageService { /** * Generates a preview for a track at the given second offset + * + * The user id names who the resulting preview cid is attested to, and the + * validator refuses unless that user already claims the source cid — + * previews stream publicly, so a preview cid anyone could mint over + * arbitrary audio would let an attacker slice a gated track into 30-second + * windows. Asserting another user's id credits them, not the caller. + * * @param {Object} params * @param {string} params.cid - The CID of the track to generate a preview for * @param {number} params.secondOffset - The offset in seconds to start the preview from + * @param {number} params.userId - Decoded id of the user the source audio belongs to * @returns {Promise} The CID of the generated preview */ async generatePreview({ cid, - secondOffset + secondOffset, + userId }: { cid: string secondOffset: number + userId: number }) { const contentNodeEndpoint = await this.storageNodeSelector.getSelectedNode() @@ -177,12 +190,12 @@ export class Storage implements StorageService { throw new Error('No content node available') } - const response = await fetch( - `${contentNodeEndpoint}/generate_preview/${cid}/${secondOffset}`, - { - method: 'POST' - } + const url = new URL( + `${contentNodeEndpoint}/generate_preview/${cid}/${secondOffset}` ) + url.searchParams.set('userId', String(userId)) + + const response = await fetch(url, { method: 'POST' }) if (!response.ok) { throw new Error( `Failed to generate preview for cid ${cid} at offset ${secondOffset}, status: ${response.status}` diff --git a/packages/sdk/src/sdk/services/Storage/types.ts b/packages/sdk/src/sdk/services/Storage/types.ts index 1b7cb446ae3..5c265f91f8e 100644 --- a/packages/sdk/src/sdk/services/Storage/types.ts +++ b/packages/sdk/src/sdk/services/Storage/types.ts @@ -79,10 +79,12 @@ export type StorageService = { getUploadStatus: (uploadId: string) => Promise generatePreview: ({ cid, - secondOffset + secondOffset, + userId }: { cid: string secondOffset: number + userId: number }) => Promise } @@ -123,4 +125,16 @@ export type FileMetadata = { userWallet?: string previewStartSeconds?: number placementHosts?: string + /** + * Decoded id of the user the upload is made for. Sent on audio uploads so + * the validator can attest on chain that these bytes were uploaded for that + * user, which is what makes the resulting cids claimable on a track. It is + * an assertion, not proof of identity — ownership is enforced when the cid + * is named on a track, in a signed entity-manager write. + * + * Image uploads leave this unset: signup uploads a profile picture before + * the account has a user id, and images are served unauthenticated anyway, + * so there is no claim to protect. + */ + userId?: number } diff --git a/packages/web/examples/gated-upload/src/App.tsx b/packages/web/examples/gated-upload/src/App.tsx index e826b5180e5..5e78a906153 100644 --- a/packages/web/examples/gated-upload/src/App.tsx +++ b/packages/web/examples/gated-upload/src/App.tsx @@ -268,7 +268,8 @@ export default function App() { setResult('Uploading audio...') const task = sdk.tracks.uploadTrackFiles({ audioFile, - imageFile: imageFileForSdk + imageFile: imageFileForSdk, + userId: profile.userId }) const { audioUploadResponse, imageUploadResponse } = await task.start() if (!audioUploadResponse?.results?.['320']) { diff --git a/packages/web/examples/upload-server/README.md b/packages/web/examples/upload-server/README.md index fc0e4b14cb4..d8d7f08544d 100644 --- a/packages/web/examples/upload-server/README.md +++ b/packages/web/examples/upload-server/README.md @@ -53,6 +53,6 @@ Open the URL shown (default `http://localhost:5176`). Sign in with Audius (popup 1. User clicks "Sign in with Audius" → popup opens, returns token via postMessage 2. Client verifies token via `verifyIDToken`, gets `userId` 3. User picks audio (required) + cover (optional), enters title/genre -4. Client calls `sdk.tracks.uploadTrackFiles({ audioFile, imageFile })` → gets trackCid, etc. +4. Client calls `sdk.tracks.uploadTrackFiles({ audioFile, imageFile, userId })` → gets trackCid, etc. 5. Client POSTs `{ userId, metadata }` to `/create-track` 6. Server uses `sdk({ apiKey, bearerToken }).tracks.createTrack()` with developer app bearer diff --git a/packages/web/examples/upload-server/src/App.tsx b/packages/web/examples/upload-server/src/App.tsx index 3bb5a8eab09..2c19f2a034a 100644 --- a/packages/web/examples/upload-server/src/App.tsx +++ b/packages/web/examples/upload-server/src/App.tsx @@ -223,7 +223,8 @@ export default function App() { setResult('Uploading audio...') const task = sdk.tracks.uploadTrackFiles({ audioFile, - imageFile: imageFileForSdk + imageFile: imageFileForSdk, + userId: profile.userId }) const { audioUploadResponse, imageUploadResponse } = await task.start() if (!audioUploadResponse?.results?.['320']) { diff --git a/packages/web/examples/upload/README.md b/packages/web/examples/upload/README.md index ab2aca96bb0..255ea5f6436 100644 --- a/packages/web/examples/upload/README.md +++ b/packages/web/examples/upload/README.md @@ -10,7 +10,7 @@ A serverless Audius track upload example using SDK + OAuth PKCE entirely in the 4. The parent's `login()` promise resolves; call `sdk.oauth.getUser()` to retrieve the authenticated user's profile. The access token is stored internally in the SDK's `tokenStore`. 5. User picks an audio file (and optional cover art), fills in title/genre/description. 6. On upload: - - `sdk.uploads.createAudioUpload({ file })` uploads audio to a storage node → returns `trackCid`, `origFileCid`, `duration`, etc. + - `sdk.uploads.createAudioUpload({ file, userId })` uploads audio to a storage node for the given user → returns `trackCid`, `origFileCid`, `duration`, etc. - `sdk.uploads.createImageUpload({ file })` uploads cover art → returns `coverArtSizes` CID. - `sdk.tracks.createTrack({ userId, metadata })` registers the track on-chain, authenticated via the stored OAuth access token. diff --git a/packages/web/examples/upload/src/App.tsx b/packages/web/examples/upload/src/App.tsx index b447d924f36..8779bc96641 100644 --- a/packages/web/examples/upload/src/App.tsx +++ b/packages/web/examples/upload/src/App.tsx @@ -180,7 +180,10 @@ export default function App() { // Step 1 — upload audio setResult('Uploading audio...') - const audioUpload = sdk.uploads.createAudioUpload({ file: audioFile }) + const audioUpload = sdk.uploads.createAudioUpload({ + file: audioFile, + userId: String(profile.userId ?? profile.sub ?? '') + }) // Step 2 — upload cover art (optional) if (coverFile) setResult('Uploading cover art...') diff --git a/packages/web/src/store/application/ui/stemsUpload/sagas.ts b/packages/web/src/store/application/ui/stemsUpload/sagas.ts index 0384a7ccebe..7453032dc60 100644 --- a/packages/web/src/store/application/ui/stemsUpload/sagas.ts +++ b/packages/web/src/store/application/ui/stemsUpload/sagas.ts @@ -6,6 +6,7 @@ import { import { Name, StemCategory } from '@audius/common/models' import { publishStems } from '@audius/common/src/api/tan-query/upload/usePublishStems' import { getContext, stemsUploadActions } from '@audius/common/store' +import { Id } from '@audius/sdk' import { takeEvery, put, call } from 'typed-redux-saga' import { make } from 'common/store/analytics/actions' @@ -35,7 +36,8 @@ function* watchUploadStems() { const sdk = await audiusSdk() const uploadHandles = uploads.map((stem, index) => { return sdk.tracks.uploadTrackFiles({ - audioFile: stem.file + audioFile: stem.file, + userId: Id.parse(userId) }) }) const uploadResponses = await Promise.all(