Compare commits

...

3 Commits

Author SHA1 Message Date
diyaa
45c270c187 Add device access token authentication 2026-06-09 12:05:15 +02:00
diyaa
2945257ea7 Align Prisma defaults with schema 2026-06-05 22:18:02 +02:00
diyaa
958ebb71f5 Add owner context and harden ownership boundaries 2026-06-04 12:47:55 +02:00
55 changed files with 2216 additions and 238 deletions

View File

@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@ -27,8 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@ -51,8 +49,6 @@
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@ -74,8 +70,6 @@
ReferencedContainer = "container:Velody.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@ -93,8 +87,6 @@
ReferencedContainer = "container:Velody.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@ -345,12 +345,7 @@ final class MacLibraryViewModel {
appVersion: environment.appVersion
)
let response = try await makeAPIClient(for: environment).registerDevice(payload)
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
try await keychainService.save(response.bootstrapToken, forKey: Self.bootstrapTokenKey)
registeredDeviceId = response.deviceId
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
try await persistRegisteredDevice(response)
} catch {
deviceRegistrationStatus = "Registration failed: \(error.localizedDescription)"
}
@ -365,7 +360,7 @@ final class MacLibraryViewModel {
do {
let environment = try currentEnvironment()
let deviceId = try await currentDeviceId()
let deviceId = try await currentOrRegisterDeviceId(for: environment)
let response = try await makeAPIClient(for: environment).sendHeartbeat(
DeviceHeartbeatPayload(
deviceId: deviceId,
@ -421,7 +416,7 @@ final class MacLibraryViewModel {
do {
let environment = try currentEnvironment()
let deviceId = try await currentDeviceId()
let deviceId = try await currentOrRegisterDeviceId(for: environment)
let fileURL = URL(fileURLWithPath: initialTrack.localFilePath)
try await withStoredFolderAccess {
@ -589,13 +584,18 @@ final class MacLibraryViewModel {
private func restoreDeviceIdentity() async {
do {
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
let deviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
)
let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey)
registeredDeviceId = deviceId
if let deviceId {
if let bootstrapToken, !bootstrapToken.isEmpty {
if let deviceAccessToken, !deviceAccessToken.isEmpty {
deviceRegistrationStatus = "Registered locally."
} else if let bootstrapToken, !bootstrapToken.isEmpty {
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Re-register to get a device access token."
} else {
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Bootstrap token is missing."
}
@ -621,23 +621,62 @@ final class MacLibraryViewModel {
)
}
private func currentDeviceId() async throws -> String {
private func currentOrRegisterDeviceId(
for environment: ServerEnvironment
) async throws -> String {
if let registeredDeviceId, !registeredDeviceId.isEmpty {
if let savedDeviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
), !savedDeviceAccessToken.isEmpty {
return registeredDeviceId
}
}
if let savedDeviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey),
let savedDeviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
),
!savedDeviceAccessToken.isEmpty,
!savedDeviceId.isEmpty
{
registeredDeviceId = savedDeviceId
return savedDeviceId
}
throw BackendConnectionError.missingDeviceIdentity
let payload = DeviceRegistrationPayload(
platform: .macos,
deviceName: Self.currentDeviceName,
appVersion: environment.appVersion
)
let response = try await makeAPIClient(for: environment).registerDevice(payload)
try await persistRegisteredDevice(response)
return response.deviceId
}
private func makeAPIClient(for environment: ServerEnvironment) -> URLSessionVelodyAPIClient {
URLSessionVelodyAPIClient(environment: environment)
URLSessionVelodyAPIClient(
environment: environment,
deviceAccessTokenProvider: { [self] in
try await self.keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
}
)
}
private func persistRegisteredDevice(
_ response: DeviceRegistrationResponse
) async throws {
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
try await keychainService.save(
response.deviceAccessToken,
forKey: Self.deviceAccessTokenKey
)
try await keychainService.save(
response.bootstrapToken,
forKey: Self.bootstrapTokenKey
)
registeredDeviceId = response.deviceId
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
}
private func currentTrack(for trackID: String) -> LibraryTrack? {
@ -847,6 +886,7 @@ final class MacLibraryViewModel {
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
private static let deviceIdKey = "velody.device-id"
private static let deviceAccessTokenKey = "velody.device-access-token"
private static let bootstrapTokenKey = "velody.bootstrap-token"
private static let playbackSessionDefaultsKey = "velody.playback.session"

View File

@ -469,7 +469,12 @@ final class iPhoneLibraryViewModel {
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "0.1.0"
)
let apiClient = URLSessionVelodyAPIClient(environment: environment)
let apiClient = URLSessionVelodyAPIClient(
environment: environment,
deviceAccessTokenProvider: {
try await keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
}
)
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
@ -657,7 +662,10 @@ final class iPhoneLibraryViewModel {
private func currentOrRegisterDeviceID() async throws -> String {
if let existingDeviceID = try await keychainService.loadValue(
forKey: Self.deviceIDKey
), !existingDeviceID.isEmpty {
), !existingDeviceID.isEmpty,
let existingDeviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
), !existingDeviceAccessToken.isEmpty {
return existingDeviceID
}
@ -670,6 +678,10 @@ final class iPhoneLibraryViewModel {
)
try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey)
try await keychainService.save(
response.deviceAccessToken,
forKey: Self.deviceAccessTokenKey
)
try await keychainService.save(
response.bootstrapToken,
forKey: Self.bootstrapTokenKey
@ -914,6 +926,7 @@ final class iPhoneLibraryViewModel {
#endif
private static let deviceIDKey = "velody.iphone.device-id"
private static let deviceAccessTokenKey = "velody.iphone.device-access-token"
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token"
private static let remoteLibraryEmptyMessage = LibrarySectionMessage(
title: "No music synced yet",

View File

@ -454,6 +454,71 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
}
}
@MainActor
final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
func testRefreshSyncStoresDeviceAccessTokenWhenRegistrationRuns() async throws {
let keychain = MemoryKeychainService()
let counter = RegisterCallCounter()
let viewModel = makeViewModel(
remoteTracks: [
makeRemoteTrack(
trackId: "remote-auth-store",
assetId: "asset-auth-store",
title: "Store Token"
),
],
apiClient: TestRegisterAPIClient(counter: counter),
keychainService: keychain
)
await viewModel.loadIfNeeded()
await viewModel.refreshSync()
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
let storedDeviceAccessToken = try await keychain.loadValue(
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
func testExistingDeviceWithoutAccessTokenReregistersCleanly() async throws {
let keychain = MemoryKeychainService()
let legacyDeviceID = "legacy-device-id"
try await keychain.save(legacyDeviceID, forKey: "velody.iphone.device-id")
try await keychain.save(
"legacy-bootstrap-token",
forKey: "velody.iphone.bootstrap-token"
)
let counter = RegisterCallCounter()
let viewModel = makeViewModel(
remoteTracks: [
makeRemoteTrack(
trackId: "remote-auth-reregister",
assetId: "asset-auth-reregister",
title: "Re-register Token"
),
],
apiClient: TestRegisterAPIClient(counter: counter),
keychainService: keychain
)
await viewModel.loadIfNeeded()
await viewModel.refreshSync()
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
let storedDeviceAccessToken = try await keychain.loadValue(
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
}
private actor RegisterCallCounter {
private(set) var count = 0

View File

@ -0,0 +1,90 @@
INSERT INTO "users" (
"id",
"slug",
"display_name",
"is_default",
"created_at",
"updated_at"
)
VALUES (
gen_random_uuid(),
'default-owner',
'Default Owner',
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT ("slug") DO UPDATE
SET
"display_name" = EXCLUDED."display_name",
"is_default" = true,
"updated_at" = CURRENT_TIMESTAMP;
DROP INDEX IF EXISTS "artwork_assets_user_id_sha256_key";
WITH "tracked_artwork_owner" AS (
SELECT DISTINCT ON ("t"."artwork_asset_id")
"t"."artwork_asset_id" AS "artwork_id",
"t"."user_id"
FROM "tracks" AS "t"
WHERE "t"."artwork_asset_id" IS NOT NULL
ORDER BY "t"."artwork_asset_id", "t"."created_at" ASC, "t"."id" ASC
)
UPDATE "artwork_assets" AS "aa"
SET "user_id" = "tracked_artwork_owner"."user_id"
FROM "tracked_artwork_owner"
WHERE "aa"."id" = "tracked_artwork_owner"."artwork_id"
AND "aa"."user_id" IS NULL;
UPDATE "artwork_assets"
SET "user_id" = (SELECT "id" FROM "users" WHERE "slug" = 'default-owner')
WHERE "user_id" IS NULL;
WITH "ranked_artwork" AS (
SELECT
"id",
"user_id",
"sha256",
FIRST_VALUE("id") OVER (
PARTITION BY "user_id", "sha256"
ORDER BY "created_at" ASC, "id" ASC
) AS "canonical_id",
ROW_NUMBER() OVER (
PARTITION BY "user_id", "sha256"
ORDER BY "created_at" ASC, "id" ASC
) AS "row_number"
FROM "artwork_assets"
),
"duplicate_artwork" AS (
SELECT "id", "canonical_id"
FROM "ranked_artwork"
WHERE "row_number" > 1
)
UPDATE "tracks" AS "t"
SET "artwork_asset_id" = "d"."canonical_id"
FROM "duplicate_artwork" AS "d"
WHERE "t"."artwork_asset_id" = "d"."id";
WITH "ranked_artwork" AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "user_id", "sha256"
ORDER BY "created_at" ASC, "id" ASC
) AS "row_number"
FROM "artwork_assets"
),
"duplicate_artwork" AS (
SELECT "id"
FROM "ranked_artwork"
WHERE "row_number" > 1
)
DELETE FROM "artwork_assets" AS "aa"
USING "duplicate_artwork" AS "d"
WHERE "aa"."id" = "d"."id";
ALTER TABLE "artwork_assets"
ALTER COLUMN "user_id" SET NOT NULL;
CREATE UNIQUE INDEX "artwork_assets_user_id_sha256_key"
ON "artwork_assets"("user_id", "sha256");

View File

@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "artwork_assets" ALTER COLUMN "id" DROP DEFAULT;
-- AlterTable
ALTER TABLE "audio_assets" ALTER COLUMN "id" DROP DEFAULT;
-- AlterTable
ALTER TABLE "device_sync_cursors" ALTER COLUMN "updated_at" DROP DEFAULT;
-- AlterTable
ALTER TABLE "devices" ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "updated_at" DROP DEFAULT;
-- AlterTable
ALTER TABLE "tracks" ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "updated_at" DROP DEFAULT;
-- AlterTable
ALTER TABLE "upload_sessions" ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "updated_at" DROP DEFAULT;
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "updated_at" DROP DEFAULT;

View File

@ -0,0 +1,7 @@
ALTER TABLE "devices"
ADD COLUMN "token_hash" TEXT,
ADD COLUMN "token_created_at" TIMESTAMP(3),
ADD COLUMN "token_last_used_at" TIMESTAMP(3),
ADD COLUMN "token_revoked_at" TIMESTAMP(3);
CREATE UNIQUE INDEX "devices_token_hash_key" ON "devices"("token_hash");

View File

@ -31,6 +31,10 @@ model Device {
deviceName String @map("device_name")
appVersion String @map("app_version")
installTokenHash String @map("install_token_hash")
tokenHash String? @unique @map("token_hash")
tokenCreatedAt DateTime? @map("token_created_at")
tokenLastUsedAt DateTime? @map("token_last_used_at")
tokenRevokedAt DateTime? @map("token_revoked_at")
lastSeenAt DateTime @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -97,7 +101,7 @@ model AudioAsset {
}
model ArtworkAsset {
userId String? @db.Uuid @map("user_id")
userId String @db.Uuid @map("user_id")
id String @id @default(uuid()) @db.Uuid
sha256 String
storageKey String @unique @map("storage_key")
@ -107,7 +111,7 @@ model ArtworkAsset {
fileSizeBytes BigInt @map("file_size_bytes")
createdAt DateTime @default(now()) @map("created_at")
tracks Track[] @relation("TrackArtwork")
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@unique([userId, sha256])
@@index([userId])

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from './modules/assets/assets.module';
import { AuthModule } from './modules/auth/auth.module';
import { ArtworkModule } from './modules/artwork/artwork.module';
import { AppConfigModule } from './modules/config/config.module';
import { DevicesModule } from './modules/devices/devices.module';
@ -7,10 +8,13 @@ import { HealthModule } from './modules/health/health.module';
import { LibraryModule } from './modules/library/library.module';
import { SyncModule } from './modules/sync/sync.module';
import { UploadsModule } from './modules/uploads/uploads.module';
import { UsersModule } from './modules/users/users.module';
@Module({
imports: [
AppConfigModule,
AuthModule,
UsersModule,
AssetsModule,
ArtworkModule,
HealthModule,

View File

@ -0,0 +1,12 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { RequestContextService } from './request-context.service';
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(private readonly requestContext: RequestContextService) {}
use(_request: Request, _response: Response, next: NextFunction): void {
this.requestContext.run(() => next());
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RequestContextMiddleware } from './request-context.middleware';
import { RequestContextService } from './request-context.service';
@Module({
providers: [RequestContextService, RequestContextMiddleware],
exports: [RequestContextService, RequestContextMiddleware],
})
export class RequestContextModule {}

View File

@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export interface AuthenticatedDeviceContextValue {
deviceId: string;
userId: string;
}
interface RequestContextState {
authenticatedDevice: AuthenticatedDeviceContextValue | null;
}
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestContextState>();
run<T>(callback: () => T): T {
return this.storage.run(
{
authenticatedDevice: null,
},
callback,
);
}
getAuthenticatedDevice(): AuthenticatedDeviceContextValue | null {
return this.storage.getStore()?.authenticatedDevice ?? null;
}
setAuthenticatedDevice(device: AuthenticatedDeviceContextValue): void {
const store = this.storage.getStore();
if (!store) {
return;
}
store.authenticatedDevice = device;
}
}

View File

@ -5,14 +5,23 @@ import {
Query,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOkResponse,
ApiProduces,
ApiTags,
} from '@nestjs/swagger';
import { createReadStream } from 'node:fs';
import { AssetDownloadQueryDto } from '../assets/assets.dto';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import { ArtworkService } from './artwork.service';
@ApiTags('artwork')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@Controller({
path: 'artwork',
version: '1',

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { StorageModule } from '../storage/storage.module';
import { UsersModule } from '../users/users.module';
import { ArtworkController } from './artwork.controller';
import { ArtworkService } from './artwork.service';
@Module({
imports: [PrismaModule, StorageModule],
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
controllers: [ArtworkController],
providers: [ArtworkService],
})

View File

@ -5,22 +5,17 @@ import { join } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { LocalFilesystemStorageService } from '../storage/storage.service';
import { ArtworkService } from './artwork.service';
type MockState = ReturnType<typeof createPrismaMock>['state'];
function createPrismaMock() {
const devices = new Map<string, any>();
const artworkAssets = new Map<string, any>();
return {
prismaMock: {
device: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return devices.get(where.id) ?? null;
}),
},
artworkAsset: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return artworkAssets.get(where.id) ?? null;
@ -28,7 +23,6 @@ function createPrismaMock() {
},
} as unknown as PrismaService,
state: {
devices,
artworkAssets,
},
};
@ -46,13 +40,28 @@ describe('ArtworkService', () => {
let state: MockState;
let storageRoot: string;
let storageService: LocalFilesystemStorageService;
let ownerUserId: string;
let deviceAuthService: {
resolveCurrentDevice: jest.Mock;
};
beforeEach(async () => {
const mock = createPrismaMock();
state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
service = new ArtworkService(mock.prismaMock, storageService);
ownerUserId = randomUUID();
deviceAuthService = {
resolveCurrentDevice: jest.fn().mockResolvedValue({
deviceId: randomUUID(),
userId: ownerUserId,
}),
};
service = new ArtworkService(
mock.prismaMock,
storageService,
deviceAuthService as unknown as DeviceAuthService,
);
});
afterEach(async () => {
@ -60,8 +69,7 @@ describe('ArtworkService', () => {
});
it('returns a local file path, content length, and mime type for the owning device user', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const userId = ownerUserId;
const artworkId = randomUUID();
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
const bytes = Buffer.from(
@ -69,70 +77,66 @@ describe('ArtworkService', () => {
'base64',
);
state.devices.set(deviceId, { id: deviceId, userId });
state.artworkAssets.set(artworkId, {
userId,
storageKey,
mimeType: 'image/png',
tracks: [{ userId }],
});
const filePath = storageService.resolve(storageKey);
await storageService.ensureParentDirectory(filePath);
await writeFile(filePath, bytes);
const download = await service.getOwnedArtworkDownload(artworkId, deviceId);
const download = await service.getOwnedArtworkDownload(artworkId, randomUUID());
expect(download.filePath).toBe(filePath);
expect(download.contentLength).toBe(bytes.length);
expect(download.mimeType).toBe('image/png');
});
it('rejects download attempts from a different user device', async () => {
const ownerId = randomUUID();
it('rejects cross-owner artwork downloads', async () => {
const otherUserId = randomUUID();
const ownerDeviceId = randomUUID();
const artworkId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
state.artworkAssets.set(artworkId, {
userId: ownerId,
storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`),
userId: otherUserId,
storageKey: join('users', otherUserId, 'artwork', `${artworkId}.jpg`),
mimeType: 'image/jpeg',
tracks: [{ userId: ownerId }],
});
await expect(
service.getOwnedArtworkDownload(artworkId, ownerDeviceId),
service.getOwnedArtworkDownload(artworkId, randomUUID()),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects foreign-owner devices before reading artwork', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
await expect(
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the artwork file is missing from storage', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const userId = ownerUserId;
const artworkId = randomUUID();
state.devices.set(deviceId, { id: deviceId, userId });
state.artworkAssets.set(artworkId, {
userId,
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
mimeType: 'image/png',
tracks: [{ userId }],
});
await expect(
service.getOwnedArtworkDownload(artworkId, deviceId),
service.getOwnedArtworkDownload(artworkId, randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the artwork asset does not exist', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
state.devices.set(deviceId, { id: deviceId, userId });
await expect(
service.getOwnedArtworkDownload(randomUUID(), deviceId),
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import { stat } from 'node:fs/promises';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { LocalFilesystemStorageService } from '../storage/storage.service';
export interface ArtworkDownload {
@ -18,22 +19,15 @@ export class ArtworkService {
constructor(
private readonly prismaService: PrismaService,
private readonly storageService: LocalFilesystemStorageService,
private readonly deviceAuthService: DeviceAuthService,
) {}
async getOwnedArtworkDownload(
artworkId: string,
deviceId: string,
legacyDeviceId?: string,
): Promise<ArtworkDownload> {
const device = await this.prismaService.device.findUnique({
where: { id: deviceId },
select: {
userId: true,
},
});
if (!device) {
throw new NotFoundException('Device not found');
}
const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
const artwork = await this.prismaService.artworkAsset.findUnique({
where: { id: artworkId },
@ -41,12 +35,6 @@ export class ArtworkService {
userId: true,
storageKey: true,
mimeType: true,
tracks: {
take: 1,
select: {
userId: true,
},
},
},
});
@ -54,12 +42,7 @@ export class ArtworkService {
throw new NotFoundException('Artwork not found');
}
const ownerUserId = artwork.userId ?? artwork.tracks[0]?.userId;
if (!ownerUserId) {
throw new NotFoundException('Artwork not found');
}
if (ownerUserId !== device.userId) {
if (artwork.userId !== ownerUserId) {
throw new ForbiddenException('Artwork does not belong to this device user.');
}

View File

@ -1,11 +1,27 @@
import { Controller, Get, Param, Query, Res, StreamableFile } from '@nestjs/common';
import {
Controller,
Get,
Param,
Query,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOkResponse,
ApiProduces,
ApiTags,
} from '@nestjs/swagger';
import { createReadStream } from 'node:fs';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import { AssetDownloadQueryDto } from './assets.dto';
import { AssetsService } from './assets.service';
@ApiTags('assets')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@Controller({
path: 'assets',
version: '1',

View File

@ -1,8 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
export class AssetDownloadQueryDto {
@ApiProperty({ format: 'uuid' })
@ApiProperty({
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
})
@IsOptional()
@IsUUID()
deviceId!: string;
deviceId?: string;
}

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { StorageModule } from '../storage/storage.module';
import { UsersModule } from '../users/users.module';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
@Module({
imports: [PrismaModule, StorageModule],
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
controllers: [AssetsController],
providers: [AssetsService],
})

View File

@ -5,22 +5,17 @@ import { join } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { LocalFilesystemStorageService } from '../storage/storage.service';
import { AssetsService } from './assets.service';
type MockState = ReturnType<typeof createPrismaMock>['state'];
function createPrismaMock() {
const devices = new Map<string, any>();
const audioAssets = new Map<string, any>();
return {
prismaMock: {
device: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return devices.get(where.id) ?? null;
}),
},
audioAsset: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return audioAssets.get(where.id) ?? null;
@ -28,7 +23,6 @@ function createPrismaMock() {
},
} as unknown as PrismaService,
state: {
devices,
audioAssets,
},
};
@ -46,13 +40,28 @@ describe('AssetsService', () => {
let state: MockState;
let storageRoot: string;
let storageService: LocalFilesystemStorageService;
let ownerUserId: string;
let deviceAuthService: {
resolveCurrentDevice: jest.Mock;
};
beforeEach(async () => {
const mock = createPrismaMock();
state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
service = new AssetsService(mock.prismaMock, storageService);
ownerUserId = randomUUID();
deviceAuthService = {
resolveCurrentDevice: jest.fn().mockResolvedValue({
deviceId: randomUUID(),
userId: ownerUserId,
}),
};
service = new AssetsService(
mock.prismaMock,
storageService,
deviceAuthService as unknown as DeviceAuthService,
);
});
afterEach(async () => {
@ -60,13 +69,11 @@ describe('AssetsService', () => {
});
it('returns a local file path and content length for the owning device user', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const userId = ownerUserId;
const assetId = randomUUID();
const storageKey = join('users', userId, 'audio', 'owner.mp3');
const assetBytes = Buffer.from('ID3-owner-track', 'utf8');
state.devices.set(deviceId, { id: deviceId, userId });
state.audioAssets.set(assetId, {
id: assetId,
userId,
@ -77,36 +84,41 @@ describe('AssetsService', () => {
await storageService.ensureParentDirectory(filePath);
await writeFile(filePath, assetBytes);
const download = await service.getOwnedAudioAssetDownload(assetId, deviceId);
const download = await service.getOwnedAudioAssetDownload(assetId, randomUUID());
expect(download.filePath).toBe(filePath);
expect(download.contentLength).toBe(assetBytes.length);
});
it('rejects download attempts from a different user device', async () => {
const ownerId = randomUUID();
it('rejects cross-owner audio asset downloads', async () => {
const otherUserId = randomUUID();
const ownerDeviceId = randomUUID();
const assetId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
state.audioAssets.set(assetId, {
id: assetId,
userId: ownerId,
storageKey: join('users', ownerId, 'audio', 'owner.mp3'),
userId: otherUserId,
storageKey: join('users', otherUserId, 'audio', 'other.mp3'),
});
await expect(
service.getOwnedAudioAssetDownload(assetId, ownerDeviceId),
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects foreign-owner devices before reading audio assets', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
await expect(
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the asset file is missing from storage', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const userId = ownerUserId;
const assetId = randomUUID();
state.devices.set(deviceId, { id: deviceId, userId });
state.audioAssets.set(assetId, {
id: assetId,
userId,
@ -114,11 +126,15 @@ describe('AssetsService', () => {
});
await expect(
service.getOwnedAudioAssetDownload(assetId, deviceId),
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the device does not exist', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
await expect(
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);

View File

@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import { stat } from 'node:fs/promises';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { LocalFilesystemStorageService } from '../storage/storage.service';
export interface AudioAssetDownload {
@ -17,22 +18,15 @@ export class AssetsService {
constructor(
private readonly prismaService: PrismaService,
private readonly storageService: LocalFilesystemStorageService,
private readonly deviceAuthService: DeviceAuthService,
) {}
async getOwnedAudioAssetDownload(
assetId: string,
deviceId: string,
legacyDeviceId?: string,
): Promise<AudioAssetDownload> {
const device = await this.prismaService.device.findUnique({
where: { id: deviceId },
select: {
userId: true,
},
});
if (!device) {
throw new NotFoundException('Device not found');
}
const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
const asset = await this.prismaService.audioAsset.findUnique({
where: { id: assetId },
@ -46,7 +40,7 @@ export class AssetsService {
throw new NotFoundException('Audio asset not found');
}
if (asset.userId !== device.userId) {
if (asset.userId !== ownerUserId) {
throw new ForbiddenException('Audio asset does not belong to this device user.');
}

View File

@ -0,0 +1,18 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
import { UsersModule } from '../users/users.module';
import { DeviceAuthGuard } from './device-auth.guard';
import { DeviceAuthService } from './device-auth.service';
@Module({
imports: [PrismaModule, RequestContextModule, UsersModule],
providers: [DeviceAuthService, DeviceAuthGuard],
exports: [DeviceAuthService, DeviceAuthGuard],
})
export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestContextMiddleware).forRoutes('*');
}
}

View File

@ -0,0 +1,31 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import type { Request } from 'express';
import { DeviceAuthService } from './device-auth.service';
@Injectable()
export class DeviceAuthGuard implements CanActivate {
constructor(private readonly deviceAuthService: DeviceAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const authorization = request.headers.authorization;
if (!authorization) {
return true;
}
const authenticatedDevice =
await this.deviceAuthService.authenticateAuthorizationHeader(
authorization,
);
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
authenticatedDevice;
return true;
}
}

View File

@ -0,0 +1,131 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
import { OwnerContext } from '../users/owner-context.service';
import { DeviceAuthService } from './device-auth.service';
describe('DeviceAuthService', () => {
it('authenticates a valid bearer token and records it in request context', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const requestContext = new RequestContextService();
const ownerContext = {
resolve: jest.fn().mockResolvedValue({ userId }),
} as OwnerContext;
const service = new DeviceAuthService(
{
device: {
findUnique: jest.fn().mockResolvedValue({
id: deviceId,
userId,
tokenRevokedAt: null,
}),
update: jest.fn().mockResolvedValue({}),
},
} as any,
requestContext,
ownerContext,
);
const token = service.generateDeviceAccessToken();
await requestContext.run(async () => {
await expect(
service.authenticateAuthorizationHeader(`Bearer ${token}`),
).resolves.toEqual({
deviceId,
userId,
});
await expect(service.resolveCurrentDevice(randomUUID())).resolves.toEqual({
deviceId,
userId,
});
});
});
it('rejects invalid or revoked tokens', async () => {
const requestContext = new RequestContextService();
const service = new DeviceAuthService(
{
device: {
findUnique: jest
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: randomUUID(),
userId: randomUUID(),
tokenRevokedAt: new Date(),
}),
update: jest.fn(),
},
} as any,
requestContext,
{
resolve: jest.fn(),
} as any,
);
await expect(
requestContext.run(() =>
service.authenticateAuthorizationHeader('Bearer invalid-token'),
),
).rejects.toBeInstanceOf(UnauthorizedException);
await expect(
requestContext.run(() =>
service.authenticateAuthorizationHeader('Bearer revoked-token'),
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('falls back to legacy device ids only when no authenticated device is present', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const otherDeviceId = randomUUID();
const requestContext = new RequestContextService();
const ownerContext = {
resolve: jest.fn().mockResolvedValue({ userId }),
} as any;
const service = new DeviceAuthService(
{
device: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
if (where.id === deviceId) {
return {
id: deviceId,
userId,
};
}
if (where.id === otherDeviceId) {
return {
id: otherDeviceId,
userId: randomUUID(),
};
}
return null;
}),
update: jest.fn(),
},
} as any,
requestContext,
ownerContext as OwnerContext,
);
await expect(
requestContext.run(() => service.resolveCurrentDevice()),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
requestContext.run(() => service.resolveCurrentDevice(otherDeviceId)),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
requestContext.run(() => service.resolveCurrentDevice(deviceId)),
).resolves.toEqual({
deviceId,
userId,
});
});
});

View File

@ -0,0 +1,123 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { createHash, randomBytes } from 'node:crypto';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import {
AuthenticatedDeviceContextValue,
RequestContextService,
} from '../../infrastructure/request-context/request-context.service';
import { OwnerContext } from '../users/owner-context.service';
@Injectable()
export class DeviceAuthService {
constructor(
private readonly prismaService: PrismaService,
private readonly requestContext: RequestContextService,
private readonly ownerContext: OwnerContext,
) {}
generateDeviceAccessToken(): string {
return randomBytes(32).toString('base64url');
}
hashDeviceAccessToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
async authenticateAuthorizationHeader(
authorizationHeader: string | string[],
): Promise<AuthenticatedDeviceContextValue> {
const token = this.extractBearerToken(authorizationHeader);
const tokenHash = this.hashDeviceAccessToken(token);
const device = await this.prismaService.device.findUnique({
where: {
tokenHash,
},
select: {
id: true,
userId: true,
tokenRevokedAt: true,
},
});
if (!device || device.tokenRevokedAt) {
throw new UnauthorizedException('Invalid device access token');
}
await this.prismaService.device.update({
where: {
id: device.id,
},
data: {
tokenLastUsedAt: new Date(),
},
});
const authenticatedDevice = {
deviceId: device.id,
userId: device.userId,
};
this.requestContext.setAuthenticatedDevice(authenticatedDevice);
return authenticatedDevice;
}
async resolveCurrentDevice(
legacyDeviceId?: string,
): Promise<AuthenticatedDeviceContextValue> {
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
if (authenticatedDevice) {
return authenticatedDevice;
}
if (!legacyDeviceId) {
throw new BadRequestException(
'deviceId is required when Authorization is missing.',
);
}
const owner = await this.ownerContext.resolve();
const device = await this.prismaService.device.findUnique({
where: {
id: legacyDeviceId,
},
select: {
id: true,
userId: true,
},
});
if (!device || device.userId !== owner.userId) {
throw new NotFoundException('Device not found');
}
return {
deviceId: device.id,
userId: device.userId,
};
}
private extractBearerToken(authorizationHeader: string | string[]): string {
const normalizedHeader = Array.isArray(authorizationHeader)
? authorizationHeader[0]
: authorizationHeader;
if (!normalizedHeader) {
throw new UnauthorizedException('Invalid device access token');
}
const match = normalizedHeader.match(/^Bearer\s+(.+)$/i);
if (!match || !match[1]?.trim()) {
throw new UnauthorizedException('Invalid device access token');
}
return match[1].trim();
}
}

View File

@ -1,5 +1,11 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import {
DeviceHeartbeatRequestDto,
DeviceHeartbeatResponseDto,
@ -25,6 +31,8 @@ export class DevicesController {
}
@Post('heartbeat')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: DeviceHeartbeatResponseDto })
async heartbeat(
@Body() body: DeviceHeartbeatRequestDto,

View File

@ -1,6 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { DevicePlatform } from '@prisma/client';
import { IsEnum, IsString, IsUUID, MinLength } from 'class-validator';
import {
IsEnum,
IsOptional,
IsString,
IsUUID,
MinLength,
} from 'class-validator';
export class RegisterDeviceRequestDto {
@ApiProperty({ enum: DevicePlatform, example: DevicePlatform.MACOS })
@ -22,6 +28,12 @@ export class RegisterDeviceResponseDto {
@ApiProperty({ format: 'uuid' })
deviceId!: string;
@ApiProperty({
description:
'Raw device access token returned only during registration. Store it securely.',
})
deviceAccessToken!: string;
@ApiProperty()
bootstrapToken!: string;
@ -30,9 +42,15 @@ export class RegisterDeviceResponseDto {
}
export class DeviceHeartbeatRequestDto {
@ApiProperty({ format: 'uuid' })
@ApiProperty({
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
})
@IsOptional()
@IsUUID()
deviceId!: string;
deviceId?: string;
@ApiProperty({ example: '0.1.0' })
@IsString()

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
import { UsersModule } from '../users/users.module';
@Module({
imports: [PrismaModule, UsersModule],
imports: [PrismaModule, UsersModule, AuthModule],
controllers: [DevicesController],
providers: [DevicesService],
exports: [DevicesService],

View File

@ -0,0 +1,86 @@
import { randomUUID } from 'node:crypto';
import { NotFoundException } from '@nestjs/common';
import { DeviceAuthService } from '../auth/device-auth.service';
import { OwnerContext } from '../users/owner-context.service';
import { DevicesService } from './devices.service';
describe('DevicesService', () => {
it('assigns newly registered devices to the bootstrap default owner', async () => {
const ownerId = randomUUID();
const prismaService = {
device: {
create: jest.fn().mockImplementation(async ({ data }) => ({
id: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
...data,
})),
},
} as any;
const ownerContext = {
resolve: jest.fn().mockResolvedValue({
userId: ownerId,
}),
} as any;
const deviceAuthService = {
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
resolveCurrentDevice: jest.fn(),
} as any;
const service = new DevicesService(
prismaService,
ownerContext as OwnerContext,
deviceAuthService as DeviceAuthService,
);
const response = await service.register({
platform: 'MACOS',
deviceName: 'Velody Mac',
appVersion: '0.1.0',
});
expect(ownerContext.resolve).toHaveBeenCalledTimes(1);
expect(prismaService.device.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: ownerId,
platform: 'MACOS',
deviceName: 'Velody Mac',
appVersion: '0.1.0',
tokenHash: 'device-token-hash',
}),
});
expect(response.deviceAccessToken).toBe('device-access-token');
});
it('rejects heartbeat updates for a foreign-owner device', async () => {
const deviceId = randomUUID();
const prismaService = {
device: {
update: jest.fn(),
},
} as any;
const ownerContext = {
resolve: jest.fn(),
} as any;
const deviceAuthService = {
generateDeviceAccessToken: jest.fn(),
hashDeviceAccessToken: jest.fn(),
resolveCurrentDevice: jest
.fn()
.mockRejectedValue(new NotFoundException('Device not found')),
} as any;
const service = new DevicesService(
prismaService,
ownerContext as OwnerContext,
deviceAuthService as DeviceAuthService,
);
await expect(
service.heartbeat({
deviceId,
appVersion: '0.1.1',
}),
).rejects.toBeInstanceOf(NotFoundException);
expect(prismaService.device.update).not.toHaveBeenCalled();
});
});

View File

@ -1,7 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { createHash, randomBytes } from 'node:crypto';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DefaultUserService } from '../users/default-user.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { OwnerContext } from '../users/owner-context.service';
import {
DeviceHeartbeatRequestDto,
DeviceHeartbeatResponseDto,
@ -13,31 +14,38 @@ import {
export class DevicesService {
constructor(
private readonly prismaService: PrismaService,
private readonly defaultUserService: DefaultUserService,
private readonly ownerContext: OwnerContext,
private readonly deviceAuthService: DeviceAuthService,
) {}
async register(
body: RegisterDeviceRequestDto,
): Promise<RegisterDeviceResponseDto> {
const bootstrapToken = randomBytes(24).toString('hex');
const deviceAccessToken = this.deviceAuthService.generateDeviceAccessToken();
const installTokenHash = createHash('sha256')
.update(bootstrapToken)
.digest('hex');
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
const tokenHash =
this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken);
const owner = await this.ownerContext.resolve();
const device = await this.prismaService.device.create({
data: {
userId: defaultUser.id,
userId: owner.userId,
platform: body.platform,
deviceName: body.deviceName,
appVersion: body.appVersion,
installTokenHash,
tokenHash,
tokenCreatedAt: new Date(),
lastSeenAt: new Date(),
},
});
return {
deviceId: device.id,
deviceAccessToken,
bootstrapToken,
serverTime: new Date().toISOString(),
};
@ -46,16 +54,10 @@ export class DevicesService {
async heartbeat(
body: DeviceHeartbeatRequestDto,
): Promise<DeviceHeartbeatResponseDto> {
const existing = await this.prismaService.device.findUnique({
where: { id: body.deviceId },
});
if (!existing) {
throw new NotFoundException('Device not found');
}
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
await this.prismaService.device.update({
where: { id: body.deviceId },
where: { id: device.deviceId },
data: {
appVersion: body.appVersion,
lastSeenAt: new Date(),
@ -67,4 +69,9 @@ export class DevicesService {
serverTime: new Date().toISOString(),
};
}
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
}

View File

@ -1,5 +1,6 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import {
LibraryTracksQueryDto,
LibraryTracksResponseDto,
@ -7,6 +8,8 @@ import {
import { LibraryService } from './library.service';
@ApiTags('library')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@Controller({
path: 'library',
version: '1',

View File

@ -1,10 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
export class LibraryTracksQueryDto {
@ApiProperty({ format: 'uuid' })
@ApiProperty({
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
})
@IsOptional()
@IsUUID()
deviceId!: string;
deviceId?: string;
}
export class RemoteArtworkDto {

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { LibraryController } from './library.controller';
import { LibraryService } from './library.service';
@Module({
imports: [PrismaModule, UsersModule],
imports: [PrismaModule, UsersModule, AuthModule],
controllers: [LibraryController],
providers: [LibraryService],
exports: [LibraryService],

View File

@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import { NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DefaultUserService } from '../users/default-user.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { OwnerContext } from '../users/owner-context.service';
import { LibraryService } from './library.service';
function createPrismaMock() {
@ -55,10 +56,22 @@ function createPrismaMock() {
describe('LibraryService', () => {
let libraryService: LibraryService;
let state: ReturnType<typeof createPrismaMock>['state'];
let ownerContextMock: {
resolve: jest.Mock;
};
let deviceAuthServiceMock: {
resolveCurrentDevice: jest.Mock;
};
beforeEach(async () => {
const { prismaMock, state: nextState } = createPrismaMock();
state = nextState;
ownerContextMock = {
resolve: jest.fn(),
};
deviceAuthServiceMock = {
resolveCurrentDevice: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
providers: [
@ -68,8 +81,12 @@ describe('LibraryService', () => {
useValue: prismaMock,
},
{
provide: DefaultUserService,
useValue: {},
provide: OwnerContext,
useValue: ownerContextMock,
},
{
provide: DeviceAuthService,
useValue: deviceAuthServiceMock,
},
],
}).compile();
@ -77,6 +94,58 @@ describe('LibraryService', () => {
libraryService = moduleRef.get(LibraryService);
});
it('returns bootstrap tracks for the default owner only', async () => {
const defaultOwnerId = randomUUID();
const otherUserId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: defaultOwnerId,
});
state.tracks.set(randomUUID(), {
id: randomUUID(),
userId: defaultOwnerId,
title: 'Bootstrap Track',
artist: 'Default Owner',
durationMs: 181000,
status: 'ACTIVE',
primaryAudioAssetId: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
state.tracks.set(randomUUID(), {
id: randomUUID(),
userId: defaultOwnerId,
title: 'Deleted Track',
artist: 'Default Owner',
durationMs: 181000,
status: 'DELETED',
primaryAudioAssetId: null,
createdAt: new Date('2026-05-29T08:05:00.000Z'),
updatedAt: new Date('2026-05-29T08:06:00.000Z'),
});
state.tracks.set(randomUUID(), {
id: randomUUID(),
userId: otherUserId,
title: 'Other User Track',
artist: 'Elsewhere',
durationMs: 182000,
status: 'ACTIVE',
primaryAudioAssetId: null,
createdAt: new Date('2026-05-29T08:10:00.000Z'),
updatedAt: new Date('2026-05-29T08:11:00.000Z'),
});
await expect(libraryService.getBootstrapTracks()).resolves.toEqual([
{
id: expect.any(String),
title: 'Bootstrap Track',
artist: 'Default Owner',
},
]);
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
});
it('returns only tracks owned by the requesting device user in created order', async () => {
const ownerId = randomUUID();
const otherUserId = randomUUID();
@ -89,7 +158,14 @@ describe('LibraryService', () => {
const otherTrackId = randomUUID();
const otherAssetId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
state.audioAssets.set(ownerAssetId, {
id: ownerAssetId,
@ -186,7 +262,51 @@ describe('LibraryService', () => {
it('returns an empty library when the user has no remote tracks', async () => {
const ownerId = randomUUID();
const ownerDeviceId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
await expect(
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
});
it('does not leak remote library tracks from other owners', async () => {
const ownerId = randomUUID();
const otherUserId = randomUUID();
const ownerDeviceId = randomUUID();
const otherTrackId = randomUUID();
const otherAssetId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
state.audioAssets.set(otherAssetId, {
id: otherAssetId,
sha256: 'sha-other-owner',
durationMs: 183000,
});
state.tracks.set(otherTrackId, {
id: otherTrackId,
userId: otherUserId,
title: 'Other Owner Track',
artist: 'Elsewhere',
durationMs: 183000,
status: 'ACTIVE',
primaryAudioAssetId: otherAssetId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
await expect(
libraryService.getRemoteLibraryTracks(ownerDeviceId),
@ -194,15 +314,45 @@ describe('LibraryService', () => {
});
it('throws for an unknown device', async () => {
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
await expect(
libraryService.getRemoteLibraryTracks(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects cross-owner track access through a foreign-owner device', async () => {
const ownerId = randomUUID();
const otherUserId = randomUUID();
const foreignDeviceId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({ userId: ownerId });
state.devices.set(foreignDeviceId, {
id: foreignDeviceId,
userId: otherUserId,
});
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
await expect(
libraryService.getRemoteLibraryTracks(foreignDeviceId),
).rejects.toBeInstanceOf(NotFoundException);
});
it('skips tracks without a primary audio asset', async () => {
const ownerId = randomUUID();
const ownerDeviceId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
state.tracks.set(randomUUID(), {
id: randomUUID(),

View File

@ -1,21 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { LibraryTrackDto } from '../sync/sync.dto';
import { DefaultUserService } from '../users/default-user.service';
import { OwnerContext } from '../users/owner-context.service';
import { RemoteLibraryTrackDto } from './library.dto';
@Injectable()
export class LibraryService {
constructor(
private readonly prismaService: PrismaService,
private readonly defaultUserService: DefaultUserService,
private readonly ownerContext: OwnerContext,
private readonly deviceAuthService: DeviceAuthService,
) {}
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
const ownerUserId = await this.resolveCurrentOwnerUserId();
const tracks = await this.prismaService.track.findMany({
where: {
userId: defaultUser.id,
userId: ownerUserId,
status: 'ACTIVE',
},
orderBy: {
@ -36,22 +38,14 @@ export class LibraryService {
}
async getRemoteLibraryTracks(
deviceId: string,
legacyDeviceId?: string,
): Promise<RemoteLibraryTrackDto[]> {
const device = await this.prismaService.device.findUnique({
where: { id: deviceId },
select: {
userId: true,
},
});
if (!device) {
throw new NotFoundException('Device not found');
}
const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
const tracks = await this.prismaService.track.findMany({
where: {
userId: device.userId,
userId: ownerUserId,
status: 'ACTIVE',
primaryAudioAssetId: {
not: null,
@ -117,4 +111,9 @@ export class LibraryService {
];
});
}
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
}

View File

@ -1,5 +1,6 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import {
SyncBootstrapResponseDto,
SyncChangesQueryDto,
@ -8,6 +9,8 @@ import {
import { SyncService } from './sync.service';
@ApiTags('sync')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@Controller({
path: 'sync',
version: '1',

View File

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { LibraryModule } from '../library/library.module';
import { UsersModule } from '../users/users.module';
import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
@Module({
imports: [PrismaModule, LibraryModule, UsersModule],
imports: [PrismaModule, LibraryModule, UsersModule, AuthModule],
controllers: [SyncController],
providers: [SyncService],
})

View File

@ -0,0 +1,58 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LibraryService } from '../library/library.service';
import { OwnerContext } from '../users/owner-context.service';
import { SyncService } from './sync.service';
describe('SyncService', () => {
it('uses OwnerContext to scope the bootstrap cursor lookup', async () => {
const ownerContextMock = {
resolve: jest.fn().mockResolvedValue({
userId: 'bootstrap-owner-id',
}),
};
const prismaMock = {
libraryEvent: {
findFirst: jest.fn().mockResolvedValue({
id: 7n,
}),
},
};
const libraryServiceMock = {
getBootstrapTracks: jest.fn().mockResolvedValue([]),
};
const moduleRef = await Test.createTestingModule({
providers: [
SyncService,
{
provide: PrismaService,
useValue: prismaMock,
},
{
provide: LibraryService,
useValue: libraryServiceMock,
},
{
provide: OwnerContext,
useValue: ownerContextMock,
},
],
}).compile();
const service = moduleRef.get(SyncService);
await expect(service.changes('0')).resolves.toMatchObject({
nextCursor: '7',
});
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
expect(prismaMock.libraryEvent.findFirst).toHaveBeenCalledWith({
where: {
userId: 'bootstrap-owner-id',
},
orderBy: {
id: 'desc',
},
});
});
});

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LibraryService } from '../library/library.service';
import { DefaultUserService } from '../users/default-user.service';
import { OwnerContext } from '../users/owner-context.service';
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
@Injectable()
@ -9,7 +9,7 @@ export class SyncService {
constructor(
private readonly prismaService: PrismaService,
private readonly libraryService: LibraryService,
private readonly defaultUserService: DefaultUserService,
private readonly ownerContext: OwnerContext,
) {}
async bootstrap(): Promise<SyncBootstrapResponseDto> {
@ -39,10 +39,10 @@ export class SyncService {
}
private async getLatestCursor(): Promise<string> {
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
const owner = await this.ownerContext.resolve();
const latest = await this.prismaService.libraryEvent.findFirst({
where: {
userId: defaultUser.id,
userId: owner.userId,
},
orderBy: {
id: 'desc',

View File

@ -6,15 +6,18 @@ import {
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import {
ApiBearerAuth,
ApiBody,
ApiCreatedResponse,
ApiConsumes,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import {
UploadFinalizeRequestDto,
UploadFinalizeResponseDto,
@ -25,6 +28,8 @@ import {
import { UploadsService } from './uploads.service';
@ApiTags('uploads')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@Controller({
path: 'uploads',
version: '1',

View File

@ -14,9 +14,15 @@ import {
} from 'class-validator';
export class UploadPrepareRequestDto {
@ApiProperty({ format: 'uuid' })
@ApiProperty({
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
})
@IsOptional()
@IsUUID()
deviceId!: string;
deviceId?: string;
@ApiProperty({
example:

View File

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { StorageModule } from '../storage/storage.module';
import { UsersModule } from '../users/users.module';
import { UploadsController } from './uploads.controller';
import { UploadsService } from './uploads.service';
@Module({
imports: [PrismaModule, StorageModule, UsersModule],
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
controllers: [UploadsController],
providers: [UploadsService],
})

View File

@ -3,10 +3,12 @@ import { readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { UnprocessableEntityException } from '@nestjs/common';
import { NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { UploadSessionStatus } from '@prisma/client';
import { AppConfigService } from '../config/config.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import { UploadsService } from './uploads.service';
type MockState = ReturnType<typeof createPrismaMock>['state'];
@ -282,6 +284,10 @@ describe('UploadsService', () => {
let storageRoot: string;
let storageService: LocalFilesystemStorageService;
let service: UploadsService;
let ownerContext: OwnerContext;
let deviceAuthService: {
resolveCurrentDevice: jest.Mock;
};
beforeEach(async () => {
const mock = createPrismaMock();
@ -289,10 +295,35 @@ describe('UploadsService', () => {
state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
ownerContext = {
resolve: jest.fn().mockResolvedValue({
userId: state.defaultUser.id,
}),
} as OwnerContext;
deviceAuthService = {
resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => {
if (!deviceId) {
throw new NotFoundException('Device not found');
}
const device = state.devices.get(deviceId);
if (!device || device.userId !== state.defaultUser.id) {
throw new NotFoundException('Device not found');
}
return {
deviceId: device.id,
userId: device.userId,
};
}),
};
service = new UploadsService(
prismaMock,
createAppConfig(storageRoot),
storageService,
ownerContext,
deviceAuthService as unknown as DeviceAuthService,
);
});
@ -334,6 +365,36 @@ describe('UploadsService', () => {
expect(state.uploadSessions.size).toBe(1);
});
it('does not reuse foreign-owner audio assets during prepare', async () => {
const device = seedDevice();
const bytes = sampleMp3Bytes('foreign-owner-asset');
const sha256 = sha256Hex(bytes);
state.audioAssets.set(randomUUID(), {
id: randomUUID(),
userId: randomUUID(),
trackId: randomUUID(),
sha256,
storageKey: 'users/foreign/audio/foreign-owner-asset.mp3',
originalFilename: 'foreign-owner-asset.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(bytes.length),
durationMs: 180000,
sourceDeviceId: randomUUID(),
createdAt: new Date(),
});
const response = await service.prepare({
deviceId: device.id,
sha256,
originalFilename: 'foreign-owner-asset.mp3',
sizeBytes: bytes.length,
});
expect(response.status).toBe('upload_required');
});
it('rejects an upload whose bytes do not match the prepared sha', async () => {
const device = seedDevice();
const uploadedBytes = sampleMp3Bytes('wrong-sha-upload');
@ -401,6 +462,14 @@ describe('UploadsService', () => {
expect(state.audioAssets.size).toBe(1);
expect(state.libraryEvents.size).toBe(1);
const track = [...state.tracks.values()][0];
const audioAsset = [...state.audioAssets.values()][0];
const libraryEvent = [...state.libraryEvents.values()][0];
expect(track.userId).toBe(state.defaultUser.id);
expect(audioAsset.userId).toBe(state.defaultUser.id);
expect(libraryEvent.userId).toBe(state.defaultUser.id);
const session = state.uploadSessions.get(response.uploadId!);
expect(session.finalizedAt).toBeInstanceOf(Date);
expect(session.trackId).toBe(finalizeResponse.trackId);
@ -550,4 +619,52 @@ describe('UploadsService', () => {
);
expect(storedArtworkBytes.equals(artworkBytes)).toBe(true);
});
it('rejects finalize when the upload session references a foreign-owner track', async () => {
const device = seedDevice();
const uploadedBytes = sampleMp3Bytes('foreign-track-finalize');
const sha256 = sha256Hex(uploadedBytes);
const response = await service.prepare({
deviceId: device.id,
sha256,
originalFilename: 'foreign-track-finalize.mp3',
sizeBytes: uploadedBytes.length,
});
await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes));
const foreignTrackId = randomUUID();
state.tracks.set(foreignTrackId, {
id: foreignTrackId,
userId: randomUUID(),
primaryAudioAssetId: null,
artworkAssetId: null,
title: 'Foreign Track',
artist: 'Elsewhere',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 180000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
});
state.uploadSessions.set(response.uploadId!, {
...state.uploadSessions.get(response.uploadId!),
trackId: foreignTrackId,
});
await expect(
service.finalize(response.uploadId!, {
title: 'Finalize Track',
artist: 'Velody',
album: 'Milestone 9',
durationMs: 245000,
}),
).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@ -1,4 +1,5 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
UnprocessableEntityException,
@ -16,7 +17,9 @@ import { access, open, rename, unlink, writeFile } from 'node:fs/promises';
import { extname } from 'node:path';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import {
UploadFinalizeArtworkDto,
UploadFinalizeRequestDto,
@ -41,6 +44,8 @@ export class UploadsService {
private readonly prismaService: PrismaService,
private readonly configService: AppConfigService,
private readonly storageService: LocalFilesystemStorageService,
private readonly ownerContext: OwnerContext,
private readonly deviceAuthService: DeviceAuthService,
) {}
async prepare(
@ -49,18 +54,13 @@ export class UploadsService {
this.assertFileSizeWithinLimit(body.sizeBytes);
this.assertMp3Filename(body.originalFilename);
const device = await this.prismaService.device.findUnique({
where: { id: body.deviceId },
});
if (!device) {
throw new NotFoundException('Device not found');
}
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
const ownerUserId = device.userId;
const existingAsset = await this.prismaService.audioAsset.findUnique({
where: {
userId_sha256: {
userId: device.userId,
userId: ownerUserId,
sha256: body.sha256,
},
},
@ -71,8 +71,8 @@ export class UploadsService {
const uploadSession = await this.prismaService.uploadSession.create({
data: {
id: uploadId,
userId: device.userId,
deviceId: body.deviceId,
userId: ownerUserId,
deviceId: device.deviceId,
trackId: existingAsset.trackId,
audioAssetId: existingAsset.id,
expectedSha256: body.sha256,
@ -98,8 +98,8 @@ export class UploadsService {
const uploadSession = await this.prismaService.uploadSession.create({
data: {
id: uploadId,
userId: device.userId,
deviceId: body.deviceId,
userId: ownerUserId,
deviceId: device.deviceId,
expectedSha256: body.sha256,
originalFilename: body.originalFilename,
expectedSizeBytes: BigInt(body.sizeBytes),
@ -118,14 +118,21 @@ export class UploadsService {
}
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
return this.toStatusResponse(await this.getUploadSessionOrThrow(uploadId));
const ownerUserId = await this.resolveCurrentOwnerUserId();
return this.toStatusResponse(
await this.getOwnedUploadSessionOrThrow(uploadId, ownerUserId),
);
}
async uploadFile(
uploadId: string,
request: Request,
): Promise<UploadSessionStatusResponseDto> {
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
const ownerUserId = await this.resolveCurrentOwnerUserId();
const uploadSession = await this.getOwnedUploadSessionOrThrow(
uploadId,
ownerUserId,
);
if (uploadSession.status === UploadSessionStatus.COMPLETED) {
return this.toStatusResponse(uploadSession);
@ -148,7 +155,7 @@ export class UploadsService {
const tempPath = this.storageService.resolve(uploadSession.tempStoragePath);
const finalPath = this.storageService.userAudioAssetPath(
uploadSession.userId,
ownerUserId,
uploadSession.expectedSha256,
);
@ -239,13 +246,28 @@ export class UploadsService {
uploadId: string,
body: UploadFinalizeRequestDto,
): Promise<UploadFinalizeResponseDto> {
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
const ownerUserId = await this.resolveCurrentOwnerUserId();
const uploadSession = await this.getOwnedUploadSessionOrThrow(
uploadId,
ownerUserId,
);
if (
uploadSession.finalizedAt &&
uploadSession.trackId &&
uploadSession.audioAssetId
) {
await this.assertOwnedTrackOrThrow(
this.prismaService,
uploadSession.trackId,
ownerUserId,
);
await this.assertOwnedAudioAssetOrThrow(
this.prismaService,
uploadSession.audioAssetId,
ownerUserId,
);
return {
trackId: uploadSession.trackId,
assetId: uploadSession.audioAssetId,
@ -281,19 +303,28 @@ export class UploadsService {
}
return this.prismaService.$transaction(async (tx) => {
const currentSession = await tx.uploadSession.findUnique({
where: { id: uploadId },
});
if (!currentSession) {
throw new NotFoundException('Upload session not found');
}
const currentSession = await this.getOwnedUploadSessionOrThrow(
uploadId,
ownerUserId,
tx,
);
if (
currentSession.finalizedAt &&
currentSession.trackId &&
currentSession.audioAssetId
) {
await this.assertOwnedTrackOrThrow(
tx,
currentSession.trackId,
ownerUserId,
);
await this.assertOwnedAudioAssetOrThrow(
tx,
currentSession.audioAssetId,
ownerUserId,
);
return {
trackId: currentSession.trackId,
assetId: currentSession.audioAssetId,
@ -308,7 +339,7 @@ export class UploadsService {
const preparedArtwork = body.artwork
? await this.prepareArtworkAssetInput(
currentSession.userId,
ownerUserId,
body.artwork,
)
: null;
@ -316,7 +347,7 @@ export class UploadsService {
let audioAsset = await tx.audioAsset.findUnique({
where: {
userId_sha256: {
userId: currentSession.userId,
userId: ownerUserId,
sha256: currentSession.expectedSha256,
},
},
@ -324,20 +355,22 @@ export class UploadsService {
let track =
currentSession.trackId != null
? await tx.track.findUnique({ where: { id: currentSession.trackId } })
? await this.findOwnedTrackById(tx, currentSession.trackId, ownerUserId)
: null;
if (currentSession.trackId != null && !track) {
throw new NotFoundException('Track not found');
}
if (!track && audioAsset?.trackId) {
track = await tx.track.findUnique({
where: { id: audioAsset.trackId },
});
track = await this.findOwnedTrackById(tx, audioAsset.trackId, ownerUserId);
}
const createdTrack = !track;
if (!track) {
track = await tx.track.create({
data: {
userId: currentSession.userId,
userId: ownerUserId,
title,
artist,
album,
@ -375,7 +408,7 @@ export class UploadsService {
} else {
audioAsset = await tx.audioAsset.create({
data: {
userId: currentSession.userId,
userId: ownerUserId,
trackId: track.id,
sha256: currentSession.expectedSha256,
storageKey: finalStorageKey,
@ -402,7 +435,7 @@ export class UploadsService {
? (
await this.findOrCreateArtworkAsset(
tx,
currentSession.userId,
ownerUserId,
preparedArtwork,
)
).id
@ -419,7 +452,7 @@ export class UploadsService {
await tx.libraryEvent.create({
data: {
userId: currentSession.userId,
userId: ownerUserId,
entityType: EntityType.TRACK,
entityId: track.id,
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
@ -443,18 +476,69 @@ export class UploadsService {
});
}
private async getUploadSessionOrThrow(uploadId: string): Promise<UploadSession> {
const uploadSession = await this.prismaService.uploadSession.findUnique({
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
private async getOwnedUploadSessionOrThrow(
uploadId: string,
ownerUserId: string,
client: Pick<PrismaService, 'uploadSession'> = this.prismaService,
): Promise<UploadSession> {
const uploadSession = await client.uploadSession.findUnique({
where: { id: uploadId },
});
if (!uploadSession) {
if (!uploadSession || uploadSession.userId !== ownerUserId) {
throw new NotFoundException('Upload session not found');
}
return uploadSession;
}
private async findOwnedTrackById(
client: Pick<PrismaService, 'track'>,
trackId: string,
ownerUserId: string,
) {
const track = await client.track.findUnique({
where: { id: trackId },
});
if (!track || track.userId !== ownerUserId) {
return null;
}
return track;
}
private async assertOwnedTrackOrThrow(
client: Pick<PrismaService, 'track'>,
trackId: string,
ownerUserId: string,
): Promise<void> {
const track = await this.findOwnedTrackById(client, trackId, ownerUserId);
if (!track) {
throw new NotFoundException('Track not found');
}
}
private async assertOwnedAudioAssetOrThrow(
client: Pick<PrismaService, 'audioAsset'>,
assetId: string,
ownerUserId: string,
): Promise<void> {
const audioAsset = await client.audioAsset.findUnique({
where: { id: assetId },
});
if (!audioAsset || audioAsset.userId !== ownerUserId) {
throw new ForbiddenException('Audio asset does not belong to the current owner.');
}
}
private toStatusResponse(
uploadSession: Pick<
UploadSession,

View File

@ -0,0 +1,59 @@
import { randomUUID } from 'node:crypto';
import { DefaultUserService } from './default-user.service';
describe('DefaultUserService', () => {
it('upserts the bootstrap default owner with stable ownership metadata', async () => {
const defaultUser = {
id: randomUUID(),
slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const prismaService = {
user: {
upsert: jest.fn().mockResolvedValue(defaultUser),
},
} as any;
const service = new DefaultUserService(prismaService);
await expect(service.getOrCreateDefaultUser()).resolves.toEqual(defaultUser);
expect(prismaService.user.upsert).toHaveBeenCalledWith({
where: {
slug: DefaultUserService.defaultOwnerSlug,
},
update: {
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
},
create: {
slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
},
});
});
it('creates the bootstrap default owner during application startup', async () => {
const service = new DefaultUserService({
user: {
upsert: jest.fn(),
},
} as any);
const getOrCreateDefaultUserSpy = jest
.spyOn(service, 'getOrCreateDefaultUser')
.mockResolvedValue({
id: randomUUID(),
slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.onApplicationBootstrap();
expect(getOrCreateDefaultUserSpy).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,14 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { User } from '@prisma/client';
import { PrismaService } from '../../infrastructure/database/prisma.service';
@Injectable()
export class DefaultUserService {
export class DefaultUserService implements OnApplicationBootstrap {
static readonly defaultOwnerSlug = 'default-owner';
static readonly defaultOwnerDisplayName = 'Default Owner';
constructor(private readonly prismaService: PrismaService) {}
async onApplicationBootstrap(): Promise<void> {
await this.getOrCreateDefaultUser();
}
async getOrCreateDefaultUser(): Promise<User> {
return this.prismaService.user.upsert({
where: {

View File

@ -0,0 +1,51 @@
import { randomUUID } from 'node:crypto';
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
import { BootstrapOwnerContextService } from './owner-context.service';
describe('BootstrapOwnerContextService', () => {
it('resolves the bootstrap default owner today', async () => {
const defaultUser = {
id: randomUUID(),
slug: 'default-owner',
displayName: 'Default Owner',
isDefault: true,
};
const defaultUserService = {
getOrCreateDefaultUser: jest.fn().mockResolvedValue(defaultUser),
} as any;
const service = new BootstrapOwnerContextService(
defaultUserService,
new RequestContextService(),
);
await expect(service.resolve()).resolves.toEqual({
userId: defaultUser.id,
});
expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1);
});
it('prefers the authenticated device owner when present', async () => {
const requestContext = new RequestContextService();
const defaultUserService = {
getOrCreateDefaultUser: jest.fn(),
} as any;
const service = new BootstrapOwnerContextService(
defaultUserService,
requestContext,
);
const authenticatedOwnerId = randomUUID();
await requestContext.run(async () => {
requestContext.setAuthenticatedDevice({
deviceId: randomUUID(),
userId: authenticatedOwnerId,
});
await expect(service.resolve()).resolves.toEqual({
userId: authenticatedOwnerId,
});
});
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
import { DefaultUserService } from './default-user.service';
export interface ResolvedOwnerContext {
userId: string;
}
export abstract class OwnerContext {
abstract resolve(): Promise<ResolvedOwnerContext>;
}
@Injectable()
export class BootstrapOwnerContextService extends OwnerContext {
constructor(
private readonly defaultUserService: DefaultUserService,
private readonly requestContext: RequestContextService,
) {
super();
}
async resolve(): Promise<ResolvedOwnerContext> {
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
if (authenticatedDevice) {
return {
userId: authenticatedDevice.userId,
};
}
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
return {
userId: defaultUser.id,
};
}
}

View File

@ -1,10 +1,22 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
import { DefaultUserService } from './default-user.service';
import {
BootstrapOwnerContextService,
OwnerContext,
} from './owner-context.service';
@Module({
imports: [PrismaModule],
providers: [DefaultUserService],
exports: [DefaultUserService],
imports: [PrismaModule, RequestContextModule],
providers: [
DefaultUserService,
BootstrapOwnerContextService,
{
provide: OwnerContext,
useExisting: BootstrapOwnerContextService,
},
],
exports: [DefaultUserService, OwnerContext],
})
export class UsersModule {}

View File

@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
import {
ForbiddenException,
NotFoundException,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
@ -13,8 +14,10 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
import { AppModule } from '../../src/app.module';
import { RequestContextService } from '../../src/infrastructure/request-context/request-context.service';
import { AssetsController } from '../../src/modules/assets/assets.controller';
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
import { DeviceAuthService } from '../../src/modules/auth/device-auth.service';
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
import { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller';
@ -38,6 +41,25 @@ function sha256Hex(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function applySelect<T extends Record<string, any>>(
record: T | null,
select?: Record<string, boolean>,
) {
if (!record) {
return null;
}
if (!select) {
return record;
}
return Object.fromEntries(
Object.entries(select)
.filter(([, enabled]) => enabled)
.map(([key]) => [key, record[key]]),
);
}
function createUploadRequest(data: Buffer): any {
const request = Readable.from([data]) as any;
request.headers = {
@ -96,17 +118,21 @@ function createPrismaMock() {
devices.set(record.id, record);
return record;
}),
findUnique: jest.fn().mockImplementation(async ({ where }) => {
const device = devices.get(where.id) ?? null;
if (!device) {
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
if (where.id) {
return applySelect(devices.get(where.id) ?? null, select);
}
if (where.tokenHash) {
const matchingDevice =
[...devices.values()].find(
(device) => device.tokenHash === where.tokenHash,
) ?? null;
return applySelect(matchingDevice, select);
}
return null;
}
if (where.id && typeof where.id === 'string') {
return device;
}
return device;
}),
update: jest.fn().mockImplementation(async ({ where, data }) => {
const current = devices.get(where.id);
@ -313,6 +339,7 @@ function createPrismaMock() {
describe('Velody API wiring (e2e)', () => {
let app: NestExpressApplication;
let prismaMock: ReturnType<typeof createPrismaMock>['prismaMock'];
let assetsController: AssetsController;
let artworkController: ArtworkController;
let healthController: HealthController;
@ -321,11 +348,15 @@ describe('Velody API wiring (e2e)', () => {
let syncController: SyncController;
let uploadsController: UploadsController;
let uploadsService: UploadsService;
let requestContextService: RequestContextService;
let deviceAuthService: DeviceAuthService;
let prismaState: ReturnType<typeof createPrismaMock>['state'];
let storageRoot: string;
beforeEach(async () => {
const { prismaMock, state } = createPrismaMock();
const prismaSetup = createPrismaMock();
prismaMock = prismaSetup.prismaMock;
const { state } = prismaSetup;
prismaState = state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
@ -367,6 +398,8 @@ describe('Velody API wiring (e2e)', () => {
syncController = moduleRef.get(SyncController);
uploadsController = moduleRef.get(UploadsController);
uploadsService = moduleRef.get(UploadsService);
requestContextService = moduleRef.get(RequestContextService);
deviceAuthService = moduleRef.get(DeviceAuthService);
});
afterEach(async () => {
@ -385,6 +418,23 @@ describe('Velody API wiring (e2e)', () => {
expect(response.version).toBe('0.1.0');
});
it('creates the bootstrap default owner during application startup', () => {
expect(prismaMock.user.upsert).toHaveBeenCalledWith({
where: {
slug: 'default-owner',
},
update: {
displayName: 'Default Owner',
isDefault: true,
},
create: {
slug: 'default-owner',
displayName: 'Default Owner',
isDefault: true,
},
});
});
it('registers a device and accepts heartbeat', async () => {
const registerResponse = await devicesController.register({
platform: 'MACOS',
@ -393,7 +443,17 @@ describe('Velody API wiring (e2e)', () => {
});
expect(registerResponse.deviceId).toBeDefined();
expect(registerResponse.deviceAccessToken).toBeDefined();
expect(registerResponse.bootstrapToken).toBeDefined();
expect(prismaState.devices.get(registerResponse.deviceId)?.userId).toBe(
prismaState.defaultUser.id,
);
expect(prismaState.devices.get(registerResponse.deviceId)?.tokenHash).toBe(
sha256Hex(Buffer.from(registerResponse.deviceAccessToken, 'utf8')),
);
expect(prismaState.devices.get(registerResponse.deviceId)?.tokenCreatedAt).toEqual(
expect.any(Date),
);
const heartbeatResponse = await devicesController.heartbeat({
deviceId: registerResponse.deviceId,
@ -403,6 +463,28 @@ describe('Velody API wiring (e2e)', () => {
expect(heartbeatResponse.ok).toBe(true);
});
it('rejects heartbeat updates for a foreign-owner device', async () => {
const foreignDeviceId = randomUUID();
prismaState.devices.set(foreignDeviceId, {
id: foreignDeviceId,
userId: randomUUID(),
platform: 'MACOS',
deviceName: 'Foreign Mac',
appVersion: '0.1.0',
installTokenHash: 'foreign-device-hash',
lastSeenAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(
devicesController.heartbeat({
deviceId: foreignDeviceId,
appVersion: '0.1.1',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns sync bootstrap and changes payloads', async () => {
const bootstrapResponse = await syncController.bootstrap();
const changesResponse = await syncController.changes({ after: '0' });
@ -412,6 +494,47 @@ describe('Velody API wiring (e2e)', () => {
expect(changesResponse.nextCursor).toBe('0');
});
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
const foreignUserId = randomUUID();
const foreignTrackId = randomUUID();
prismaState.tracks.set(foreignTrackId, {
id: foreignTrackId,
userId: foreignUserId,
primaryAudioAssetId: null,
artworkAssetId: null,
title: 'Foreign Bootstrap Track',
artist: 'Elsewhere',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 180000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
prismaState.libraryEvents.set(1n, {
id: 1n,
userId: foreignUserId,
entityType: 'TRACK',
entityId: foreignTrackId,
action: 'CREATED',
payloadVersion: 1,
createdAt: new Date('2026-05-29T08:02:00.000Z'),
});
const bootstrapResponse = await syncController.bootstrap();
const changesResponse = await syncController.changes({ after: '0' });
expect(bootstrapResponse.tracks).toEqual([]);
expect(changesResponse.events).toEqual([]);
expect(changesResponse.nextCursor).toBe('0');
});
it('downloads audio asset bytes for the owning device user', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
@ -610,6 +733,36 @@ describe('Velody API wiring (e2e)', () => {
expect(headers.get('content-length')).toBe(String(bytes.length));
});
it('rejects artwork download requests for another user artwork', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Artwork iPhone',
appVersion: '0.1.0',
});
const artworkId = randomUUID();
const otherUserId = randomUUID();
prismaState.artworkAssets.set(artworkId, {
id: artworkId,
userId: otherUserId,
sha256: 'sha-other-artwork',
storageKey: join('users', otherUserId, 'artwork', 'sha-other-artwork.png'),
mimeType: 'image/png',
width: 1,
height: 1,
fileSizeBytes: BigInt(10),
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
await expect(
artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('returns not found when the requested artwork file is missing', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
@ -870,6 +1023,304 @@ describe('Velody API wiring (e2e)', () => {
});
});
it('authenticates remote library requests with a valid device token and ignores spoofed device ids', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Auth iPhone',
appVersion: '0.1.0',
});
const foreignUserId = randomUUID();
const foreignDeviceId = randomUUID();
const ownerTrackId = randomUUID();
const ownerAssetId = randomUUID();
const foreignTrackId = randomUUID();
const foreignAssetId = randomUUID();
prismaState.devices.set(foreignDeviceId, {
id: foreignDeviceId,
userId: foreignUserId,
platform: 'IPHONE',
deviceName: 'Spoofed Foreign iPhone',
appVersion: '0.1.0',
installTokenHash: 'spoofed-device-hash',
tokenHash: sha256Hex(Buffer.from('foreign-device-token', 'utf8')),
tokenCreatedAt: new Date(),
lastSeenAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
prismaState.audioAssets.set(ownerAssetId, {
id: ownerAssetId,
userId: prismaState.defaultUser.id,
trackId: ownerTrackId,
sha256: 'owner-library-sha',
storageKey: 'users/default/audio/owner-library-sha.mp3',
originalFilename: 'owner-library.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(42),
durationMs: 183000,
sourceDeviceId: ownerDevice.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
prismaState.audioAssets.set(foreignAssetId, {
id: foreignAssetId,
userId: foreignUserId,
trackId: foreignTrackId,
sha256: 'foreign-library-sha',
storageKey: 'users/foreign/audio/foreign-library-sha.mp3',
originalFilename: 'foreign-library.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(42),
durationMs: 204000,
sourceDeviceId: foreignDeviceId,
createdAt: new Date('2026-05-29T08:01:00.000Z'),
});
prismaState.tracks.set(ownerTrackId, {
id: ownerTrackId,
userId: prismaState.defaultUser.id,
primaryAudioAssetId: ownerAssetId,
artworkAssetId: null,
title: 'Authenticated Owner Track',
artist: 'Velody',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 183000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
});
prismaState.tracks.set(foreignTrackId, {
id: foreignTrackId,
userId: foreignUserId,
primaryAudioAssetId: foreignAssetId,
artworkAssetId: null,
title: 'Foreign Track',
artist: 'Elsewhere',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 204000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:03:00.000Z'),
updatedAt: new Date('2026-05-29T08:04:00.000Z'),
});
await requestContextService.run(async () => {
await deviceAuthService.authenticateAuthorizationHeader(
`Bearer ${ownerDevice.deviceAccessToken}`,
);
await expect(
libraryController.getTracks({
deviceId: foreignDeviceId,
}),
).resolves.toEqual({
tracks: [
expect.objectContaining({
trackId: ownerTrackId,
title: 'Authenticated Owner Track',
assetId: ownerAssetId,
}),
],
});
});
});
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Legacy iPhone',
appVersion: '0.1.0',
});
const trackId = randomUUID();
const assetId = randomUUID();
prismaState.audioAssets.set(assetId, {
id: assetId,
userId: prismaState.defaultUser.id,
trackId,
sha256: 'legacy-library-sha',
storageKey: 'users/default/audio/legacy-library-sha.mp3',
originalFilename: 'legacy-library.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(42),
durationMs: 210000,
sourceDeviceId: ownerDevice.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
prismaState.tracks.set(trackId, {
id: trackId,
userId: prismaState.defaultUser.id,
primaryAudioAssetId: assetId,
artworkAssetId: null,
title: 'Legacy Library Track',
artist: 'Velody',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 210000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
});
const response = await libraryController.getTracks({
deviceId: ownerDevice.deviceId,
});
expect(response.tracks).toEqual([
expect.objectContaining({
trackId,
assetId,
}),
]);
});
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Rejected iPhone',
appVersion: '0.1.0',
});
await requestContextService.run(async () => {
await expect(
deviceAuthService.authenticateAuthorizationHeader(
'Bearer invalid-device-token',
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
prismaState.devices.set(ownerDevice.deviceId, {
...prismaState.devices.get(ownerDevice.deviceId),
tokenRevokedAt: new Date('2026-06-09T10:00:00.000Z'),
});
await requestContextService.run(async () => {
await expect(
deviceAuthService.authenticateAuthorizationHeader(
`Bearer ${ownerDevice.deviceAccessToken}`,
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
it('rejects cross-owner asset and artwork downloads for authenticated tokens', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Owner iPhone',
appVersion: '0.1.0',
});
const foreignUserId = randomUUID();
const foreignDeviceId = randomUUID();
const foreignAssetId = randomUUID();
const foreignArtworkId = randomUUID();
prismaState.devices.set(foreignDeviceId, {
id: foreignDeviceId,
userId: foreignUserId,
platform: 'IPHONE',
deviceName: 'Foreign iPhone',
appVersion: '0.1.0',
installTokenHash: 'foreign-install-hash',
tokenHash: sha256Hex(Buffer.from('foreign-access-token', 'utf8')),
tokenCreatedAt: new Date(),
lastSeenAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
prismaState.audioAssets.set(foreignAssetId, {
id: foreignAssetId,
userId: foreignUserId,
trackId: randomUUID(),
sha256: 'foreign-asset-sha',
storageKey: 'users/foreign/audio/foreign-asset-sha.mp3',
originalFilename: 'foreign-asset.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(64),
durationMs: 190000,
sourceDeviceId: foreignDeviceId,
createdAt: new Date(),
});
prismaState.artworkAssets.set(foreignArtworkId, {
id: foreignArtworkId,
userId: foreignUserId,
sha256: 'foreign-artwork-sha',
storageKey: 'users/foreign/artwork/foreign-artwork-sha.png',
mimeType: 'image/png',
width: 512,
height: 512,
fileSizeBytes: BigInt(64),
createdAt: new Date(),
tracks: [],
});
await requestContextService.run(async () => {
await deviceAuthService.authenticateAuthorizationHeader(
`Bearer ${ownerDevice.deviceAccessToken}`,
);
await expect(
assetsController.download(
foreignAssetId,
{},
{ setHeader() {} } as any,
),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
artworkController.download(
foreignArtworkId,
{},
{ setHeader() {} } as any,
),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
it('updates tokenLastUsedAt during authenticated heartbeat', async () => {
const ownerDevice = await devicesController.register({
platform: 'MACOS',
deviceName: 'Heartbeat Mac',
appVersion: '0.1.0',
});
expect(prismaState.devices.get(ownerDevice.deviceId)?.tokenLastUsedAt).toBeUndefined();
await requestContextService.run(async () => {
await deviceAuthService.authenticateAuthorizationHeader(
`Bearer ${ownerDevice.deviceAccessToken}`,
);
const response = await devicesController.heartbeat({
appVersion: '0.1.1',
});
expect(response.ok).toBe(true);
});
expect(prismaState.devices.get(ownerDevice.deviceId)?.tokenLastUsedAt).toEqual(
expect.any(Date),
);
});
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
const registerResponse = await devicesController.register({
platform: 'MACOS',

View File

@ -93,15 +93,18 @@ public struct DeviceRegistrationPayload: Codable, Hashable, Sendable {
public struct DeviceRegistrationResponse: Codable, Hashable, Sendable {
public var deviceId: String
public var deviceAccessToken: String
public var bootstrapToken: String
public var serverTime: String
public init(
deviceId: String,
deviceAccessToken: String,
bootstrapToken: String,
serverTime: String
) {
self.deviceId = deviceId
self.deviceAccessToken = deviceAccessToken
self.bootstrapToken = bootstrapToken
self.serverTime = serverTime
}

View File

@ -73,21 +73,26 @@ public protocol VelodyAPIClient: Sendable {
) async throws -> UploadFinalizeResponse
}
public typealias DeviceAccessTokenProvider = @Sendable () async throws -> String?
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
public let environment: ServerEnvironment
private let session: URLSession
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let deviceAccessTokenProvider: DeviceAccessTokenProvider?
public init(
environment: ServerEnvironment,
session: URLSession = .shared
session: URLSession = .shared,
deviceAccessTokenProvider: DeviceAccessTokenProvider? = nil
) {
self.environment = environment
self.session = session
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
self.deviceAccessTokenProvider = deviceAccessTokenProvider
}
public func registerDevice(
@ -108,6 +113,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: "POST",
pathComponents: ["api", "v1", "devices", "heartbeat"],
body: payload,
includesDeviceAuthorization: true,
responseType: DeviceHeartbeatResponse.self
)
}
@ -116,6 +122,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "sync", "bootstrap"],
includesDeviceAuthorization: true,
responseType: SyncBootstrapResponse.self
)
}
@ -129,6 +136,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
includesDeviceAuthorization: true,
responseType: RemoteLibraryResponseDTO.self
)
}
@ -137,13 +145,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
assetId: String,
deviceId: String
) async throws -> Data {
let request = try buildRequest(
let request = try await buildRequest(
method: "GET",
pathComponents: ["api", "v1", "assets", assetId, "download"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
bodyData: nil,
includesDeviceAuthorization: true,
acceptType: "audio/mpeg"
)
@ -154,13 +163,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
artworkId: String,
deviceId: String
) async throws -> Data {
let request = try buildRequest(
let request = try await buildRequest(
method: "GET",
pathComponents: ["api", "v1", "artwork", artworkId, "download"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
bodyData: nil,
includesDeviceAuthorization: true,
acceptType: "image/*"
)
@ -174,6 +184,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: "POST",
pathComponents: ["api", "v1", "uploads", "prepare"],
body: payload,
includesDeviceAuthorization: true,
responseType: UploadPrepareResponse.self
)
}
@ -184,6 +195,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "uploads", uploadId],
includesDeviceAuthorization: true,
responseType: UploadSessionStatusResponse.self
)
}
@ -197,11 +209,12 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
throw VelodyAPIError.requestFailed("The selected file could not be found.")
}
let request = try buildRequest(
let request = try await buildRequest(
method: "PUT",
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
queryItems: [],
bodyData: nil,
includesDeviceAuthorization: true,
contentType: mimeType
)
@ -229,6 +242,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: "POST",
pathComponents: ["api", "v1", "uploads", uploadId, "finalize"],
body: payload,
includesDeviceAuthorization: true,
responseType: UploadFinalizeResponse.self
)
}
@ -237,13 +251,15 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: String,
pathComponents: [String],
queryItems: [URLQueryItem] = [],
includesDeviceAuthorization: Bool = false,
responseType: Response.Type
) async throws -> Response {
let request = try buildRequest(
let request = try await buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: nil
bodyData: nil,
includesDeviceAuthorization: includesDeviceAuthorization
)
return try await execute(request, responseType: responseType)
@ -254,6 +270,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
pathComponents: [String],
queryItems: [URLQueryItem] = [],
body: Body,
includesDeviceAuthorization: Bool = false,
responseType: Response.Type
) async throws -> Response {
let bodyData: Data
@ -264,11 +281,12 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
let request = try buildRequest(
let request = try await buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: bodyData,
includesDeviceAuthorization: includesDeviceAuthorization,
contentType: "application/json"
)
@ -280,9 +298,10 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
pathComponents: [String],
queryItems: [URLQueryItem],
bodyData: Data?,
includesDeviceAuthorization: Bool = false,
contentType: String? = nil,
acceptType: String = "application/json"
) throws -> URLRequest {
) async throws -> URLRequest {
guard let url = endpointURL(
pathComponents: pathComponents,
queryItems: queryItems
@ -302,9 +321,30 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
if includesDeviceAuthorization,
let deviceAccessToken = try await loadDeviceAccessToken(),
!deviceAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
request.setValue(
"Bearer \(deviceAccessToken)",
forHTTPHeaderField: "Authorization"
)
}
return request
}
private func loadDeviceAccessToken() async throws -> String? {
guard let deviceAccessTokenProvider else {
return nil
}
do {
return try await deviceAccessTokenProvider()
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
}
private func execute<Response: Decodable>(
_ request: URLRequest,
responseType: Response.Type
@ -404,6 +444,7 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
deviceAccessToken: "stub-device-access-token",
bootstrapToken: "stub-bootstrap-token",
serverTime: ISO8601DateFormatter().string(from: .now)
)

View File

@ -0,0 +1,130 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyNetworking
final class URLSessionVelodyAPIClientAuthorizationTests: XCTestCase {
override func tearDown() {
RecordingURLProtocol.handler = nil
super.tearDown()
}
func testFetchRemoteLibrarySendsAuthorizationHeaderWhenDeviceTokenIsAvailable() async throws {
RecordingURLProtocol.handler = { request in
XCTAssertEqual(
request.value(forHTTPHeaderField: "Authorization"),
"Bearer test-device-access-token"
)
XCTAssertEqual(request.url?.path, "/api/v1/library/tracks")
XCTAssertEqual(request.url?.query, "deviceId=device-123")
return (
HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!,
Data(#"{"tracks":[]}"#.utf8)
)
}
let client = URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: URL(string: "http://127.0.0.1:3007")!,
appVersion: "Tests"
),
session: makeSession(),
deviceAccessTokenProvider: {
"test-device-access-token"
}
)
let response = try await client.fetchRemoteLibrary(deviceId: "device-123")
XCTAssertEqual(response.tracks.count, 0)
}
func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws {
RecordingURLProtocol.handler = { request in
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
XCTAssertEqual(request.url?.path, "/api/v1/devices/register")
return (
HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!,
Data(
"""
{
"deviceId": "device-123",
"deviceAccessToken": "registered-device-token",
"bootstrapToken": "bootstrap-token",
"serverTime": "2026-06-09T10:00:00.000Z"
}
""".utf8
)
)
}
let client = URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: URL(string: "http://127.0.0.1:3007")!,
appVersion: "Tests"
),
session: makeSession(),
deviceAccessTokenProvider: {
"should-not-be-sent"
}
)
let response = try await client.registerDevice(
DeviceRegistrationPayload(
platform: .iphone,
deviceName: "Test iPhone",
appVersion: "Tests"
)
)
XCTAssertEqual(response.deviceId, "device-123")
XCTAssertEqual(response.deviceAccessToken, "registered-device-token")
}
private func makeSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [RecordingURLProtocol.self]
return URLSession(configuration: configuration)
}
}
private final class RecordingURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "127.0.0.1"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let handler = Self.handler else {
XCTFail("RecordingURLProtocol.handler must be set before use.")
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}

View File

@ -427,6 +427,7 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
deviceAccessToken: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-05-30T08:00:00.000Z"
)

View File

@ -345,6 +345,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
deviceAccessToken: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-05-29T08:00:00.000Z"
)