Add owner context and harden ownership boundaries

This commit is contained in:
diyaa 2026-06-04 12:47:55 +02:00
parent d392e532e0
commit 958ebb71f5
23 changed files with 938 additions and 96 deletions

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

@ -97,7 +97,7 @@ model AudioAsset {
} }
model ArtworkAsset { model ArtworkAsset {
userId String? @db.Uuid @map("user_id") userId String @db.Uuid @map("user_id")
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
sha256 String sha256 String
storageKey String @unique @map("storage_key") storageKey String @unique @map("storage_key")
@ -107,7 +107,7 @@ model ArtworkAsset {
fileSizeBytes BigInt @map("file_size_bytes") fileSizeBytes BigInt @map("file_size_bytes")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
tracks Track[] @relation("TrackArtwork") 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]) @@unique([userId, sha256])
@@index([userId]) @@index([userId])

View File

@ -7,10 +7,12 @@ import { HealthModule } from './modules/health/health.module';
import { LibraryModule } from './modules/library/library.module'; import { LibraryModule } from './modules/library/library.module';
import { SyncModule } from './modules/sync/sync.module'; import { SyncModule } from './modules/sync/sync.module';
import { UploadsModule } from './modules/uploads/uploads.module'; import { UploadsModule } from './modules/uploads/uploads.module';
import { UsersModule } from './modules/users/users.module';
@Module({ @Module({
imports: [ imports: [
AppConfigModule, AppConfigModule,
UsersModule,
AssetsModule, AssetsModule,
ArtworkModule, ArtworkModule,
HealthModule, HealthModule,

View File

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

View File

@ -6,6 +6,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service'; import { AppConfigService } from '../config/config.service';
import { LocalFilesystemStorageService } from '../storage/storage.service'; import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import { ArtworkService } from './artwork.service'; import { ArtworkService } from './artwork.service';
type MockState = ReturnType<typeof createPrismaMock>['state']; type MockState = ReturnType<typeof createPrismaMock>['state'];
@ -46,13 +47,23 @@ describe('ArtworkService', () => {
let state: MockState; let state: MockState;
let storageRoot: string; let storageRoot: string;
let storageService: LocalFilesystemStorageService; let storageService: LocalFilesystemStorageService;
let ownerUserId: string;
beforeEach(async () => { beforeEach(async () => {
const mock = createPrismaMock(); const mock = createPrismaMock();
state = mock.state; state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-')); storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
service = new ArtworkService(mock.prismaMock, storageService); ownerUserId = randomUUID();
service = new ArtworkService(
mock.prismaMock,
storageService,
{
resolve: jest.fn().mockResolvedValue({
userId: ownerUserId,
}),
} as OwnerContext,
);
}); });
afterEach(async () => { afterEach(async () => {
@ -60,7 +71,7 @@ describe('ArtworkService', () => {
}); });
it('returns a local file path, content length, and mime type for the owning device user', async () => { it('returns a local file path, content length, and mime type for the owning device user', async () => {
const userId = randomUUID(); const userId = ownerUserId;
const deviceId = randomUUID(); const deviceId = randomUUID();
const artworkId = randomUUID(); const artworkId = randomUUID();
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`); const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
@ -74,7 +85,6 @@ describe('ArtworkService', () => {
userId, userId,
storageKey, storageKey,
mimeType: 'image/png', mimeType: 'image/png',
tracks: [{ userId }],
}); });
const filePath = storageService.resolve(storageKey); const filePath = storageService.resolve(storageKey);
@ -88,18 +98,16 @@ describe('ArtworkService', () => {
expect(download.mimeType).toBe('image/png'); expect(download.mimeType).toBe('image/png');
}); });
it('rejects download attempts from a different user device', async () => { it('rejects cross-owner artwork downloads', async () => {
const ownerId = randomUUID();
const otherUserId = randomUUID(); const otherUserId = randomUUID();
const ownerDeviceId = randomUUID(); const ownerDeviceId = randomUUID();
const artworkId = randomUUID(); const artworkId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId });
state.artworkAssets.set(artworkId, { state.artworkAssets.set(artworkId, {
userId: ownerId, userId: otherUserId,
storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`), storageKey: join('users', otherUserId, 'artwork', `${artworkId}.jpg`),
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
tracks: [{ userId: ownerId }],
}); });
await expect( await expect(
@ -107,8 +115,21 @@ describe('ArtworkService', () => {
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('rejects foreign-owner devices before reading artwork', async () => {
const foreignDeviceId = randomUUID();
state.devices.set(foreignDeviceId, {
id: foreignDeviceId,
userId: randomUUID(),
});
await expect(
service.getOwnedArtworkDownload(randomUUID(), foreignDeviceId),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the artwork file is missing from storage', async () => { it('returns not found when the artwork file is missing from storage', async () => {
const userId = randomUUID(); const userId = ownerUserId;
const deviceId = randomUUID(); const deviceId = randomUUID();
const artworkId = randomUUID(); const artworkId = randomUUID();
@ -117,7 +138,6 @@ describe('ArtworkService', () => {
userId, userId,
storageKey: join('users', userId, 'artwork', `${artworkId}.png`), storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
mimeType: 'image/png', mimeType: 'image/png',
tracks: [{ userId }],
}); });
await expect( await expect(

View File

@ -6,6 +6,7 @@ import {
import { stat } from 'node:fs/promises'; import { stat } from 'node:fs/promises';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LocalFilesystemStorageService } from '../storage/storage.service'; import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
export interface ArtworkDownload { export interface ArtworkDownload {
filePath: string; filePath: string;
@ -18,12 +19,14 @@ export class ArtworkService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly storageService: LocalFilesystemStorageService, private readonly storageService: LocalFilesystemStorageService,
private readonly ownerContext: OwnerContext,
) {} ) {}
async getOwnedArtworkDownload( async getOwnedArtworkDownload(
artworkId: string, artworkId: string,
deviceId: string, deviceId: string,
): Promise<ArtworkDownload> { ): Promise<ArtworkDownload> {
const ownerUserId = await this.resolveCurrentOwnerUserId();
const device = await this.prismaService.device.findUnique({ const device = await this.prismaService.device.findUnique({
where: { id: deviceId }, where: { id: deviceId },
select: { select: {
@ -31,7 +34,7 @@ export class ArtworkService {
}, },
}); });
if (!device) { if (!device || device.userId !== ownerUserId) {
throw new NotFoundException('Device not found'); throw new NotFoundException('Device not found');
} }
@ -41,12 +44,6 @@ export class ArtworkService {
userId: true, userId: true,
storageKey: true, storageKey: true,
mimeType: true, mimeType: true,
tracks: {
take: 1,
select: {
userId: true,
},
},
}, },
}); });
@ -54,12 +51,7 @@ export class ArtworkService {
throw new NotFoundException('Artwork not found'); throw new NotFoundException('Artwork not found');
} }
const ownerUserId = artwork.userId ?? artwork.tracks[0]?.userId; if (artwork.userId !== ownerUserId) {
if (!ownerUserId) {
throw new NotFoundException('Artwork not found');
}
if (ownerUserId !== device.userId) {
throw new ForbiddenException('Artwork does not belong to this device user.'); throw new ForbiddenException('Artwork does not belong to this device user.');
} }
@ -85,4 +77,9 @@ export class ArtworkService {
throw new NotFoundException('Artwork file not found'); throw new NotFoundException('Artwork file not found');
} }
} }
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
} }

View File

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

View File

@ -6,6 +6,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service'; import { AppConfigService } from '../config/config.service';
import { LocalFilesystemStorageService } from '../storage/storage.service'; import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
type MockState = ReturnType<typeof createPrismaMock>['state']; type MockState = ReturnType<typeof createPrismaMock>['state'];
@ -46,13 +47,23 @@ describe('AssetsService', () => {
let state: MockState; let state: MockState;
let storageRoot: string; let storageRoot: string;
let storageService: LocalFilesystemStorageService; let storageService: LocalFilesystemStorageService;
let ownerUserId: string;
beforeEach(async () => { beforeEach(async () => {
const mock = createPrismaMock(); const mock = createPrismaMock();
state = mock.state; state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-')); storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
service = new AssetsService(mock.prismaMock, storageService); ownerUserId = randomUUID();
service = new AssetsService(
mock.prismaMock,
storageService,
{
resolve: jest.fn().mockResolvedValue({
userId: ownerUserId,
}),
} as OwnerContext,
);
}); });
afterEach(async () => { afterEach(async () => {
@ -60,7 +71,7 @@ describe('AssetsService', () => {
}); });
it('returns a local file path and content length for the owning device user', async () => { it('returns a local file path and content length for the owning device user', async () => {
const userId = randomUUID(); const userId = ownerUserId;
const deviceId = randomUUID(); const deviceId = randomUUID();
const assetId = randomUUID(); const assetId = randomUUID();
const storageKey = join('users', userId, 'audio', 'owner.mp3'); const storageKey = join('users', userId, 'audio', 'owner.mp3');
@ -83,17 +94,16 @@ describe('AssetsService', () => {
expect(download.contentLength).toBe(assetBytes.length); expect(download.contentLength).toBe(assetBytes.length);
}); });
it('rejects download attempts from a different user device', async () => { it('rejects cross-owner audio asset downloads', async () => {
const ownerId = randomUUID();
const otherUserId = randomUUID(); const otherUserId = randomUUID();
const ownerDeviceId = randomUUID(); const ownerDeviceId = randomUUID();
const assetId = randomUUID(); const assetId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId });
state.audioAssets.set(assetId, { state.audioAssets.set(assetId, {
id: assetId, id: assetId,
userId: ownerId, userId: otherUserId,
storageKey: join('users', ownerId, 'audio', 'owner.mp3'), storageKey: join('users', otherUserId, 'audio', 'other.mp3'),
}); });
await expect( await expect(
@ -101,8 +111,22 @@ describe('AssetsService', () => {
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('rejects foreign-owner devices before reading audio assets', async () => {
const foreignOwnerId = randomUUID();
const foreignDeviceId = randomUUID();
state.devices.set(foreignDeviceId, {
id: foreignDeviceId,
userId: foreignOwnerId,
});
await expect(
service.getOwnedAudioAssetDownload(randomUUID(), foreignDeviceId),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the asset file is missing from storage', async () => { it('returns not found when the asset file is missing from storage', async () => {
const userId = randomUUID(); const userId = ownerUserId;
const deviceId = randomUUID(); const deviceId = randomUUID();
const assetId = randomUUID(); const assetId = randomUUID();

View File

@ -6,6 +6,7 @@ import {
import { stat } from 'node:fs/promises'; import { stat } from 'node:fs/promises';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LocalFilesystemStorageService } from '../storage/storage.service'; import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
export interface AudioAssetDownload { export interface AudioAssetDownload {
filePath: string; filePath: string;
@ -17,12 +18,14 @@ export class AssetsService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly storageService: LocalFilesystemStorageService, private readonly storageService: LocalFilesystemStorageService,
private readonly ownerContext: OwnerContext,
) {} ) {}
async getOwnedAudioAssetDownload( async getOwnedAudioAssetDownload(
assetId: string, assetId: string,
deviceId: string, deviceId: string,
): Promise<AudioAssetDownload> { ): Promise<AudioAssetDownload> {
const ownerUserId = await this.resolveCurrentOwnerUserId();
const device = await this.prismaService.device.findUnique({ const device = await this.prismaService.device.findUnique({
where: { id: deviceId }, where: { id: deviceId },
select: { select: {
@ -30,7 +33,7 @@ export class AssetsService {
}, },
}); });
if (!device) { if (!device || device.userId !== ownerUserId) {
throw new NotFoundException('Device not found'); throw new NotFoundException('Device not found');
} }
@ -46,7 +49,7 @@ export class AssetsService {
throw new NotFoundException('Audio asset not found'); 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.'); throw new ForbiddenException('Audio asset does not belong to this device user.');
} }
@ -71,4 +74,9 @@ export class AssetsService {
throw new NotFoundException('Audio asset file not found'); throw new NotFoundException('Audio asset file not found');
} }
} }
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
} }

View File

@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import { NotFoundException } from '@nestjs/common';
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 service = new DevicesService(
prismaService,
ownerContext as OwnerContext,
);
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',
}),
});
});
it('rejects heartbeat updates for a foreign-owner device', async () => {
const ownerId = randomUUID();
const foreignOwnerId = randomUUID();
const deviceId = randomUUID();
const prismaService = {
device: {
findUnique: jest.fn().mockResolvedValue({
userId: foreignOwnerId,
}),
update: jest.fn(),
},
} as any;
const ownerContext = {
resolve: jest.fn().mockResolvedValue({
userId: ownerId,
}),
} as any;
const service = new DevicesService(
prismaService,
ownerContext as OwnerContext,
);
await expect(
service.heartbeat({
deviceId,
appVersion: '0.1.1',
}),
).rejects.toBeInstanceOf(NotFoundException);
expect(prismaService.device.update).not.toHaveBeenCalled();
});
});

View File

@ -1,7 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DefaultUserService } from '../users/default-user.service'; import { OwnerContext } from '../users/owner-context.service';
import { import {
DeviceHeartbeatRequestDto, DeviceHeartbeatRequestDto,
DeviceHeartbeatResponseDto, DeviceHeartbeatResponseDto,
@ -13,7 +13,7 @@ import {
export class DevicesService { export class DevicesService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly defaultUserService: DefaultUserService, private readonly ownerContext: OwnerContext,
) {} ) {}
async register( async register(
@ -23,11 +23,11 @@ export class DevicesService {
const installTokenHash = createHash('sha256') const installTokenHash = createHash('sha256')
.update(bootstrapToken) .update(bootstrapToken)
.digest('hex'); .digest('hex');
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); const owner = await this.ownerContext.resolve();
const device = await this.prismaService.device.create({ const device = await this.prismaService.device.create({
data: { data: {
userId: defaultUser.id, userId: owner.userId,
platform: body.platform, platform: body.platform,
deviceName: body.deviceName, deviceName: body.deviceName,
appVersion: body.appVersion, appVersion: body.appVersion,
@ -46,11 +46,15 @@ export class DevicesService {
async heartbeat( async heartbeat(
body: DeviceHeartbeatRequestDto, body: DeviceHeartbeatRequestDto,
): Promise<DeviceHeartbeatResponseDto> { ): Promise<DeviceHeartbeatResponseDto> {
const ownerUserId = await this.resolveCurrentOwnerUserId();
const existing = await this.prismaService.device.findUnique({ const existing = await this.prismaService.device.findUnique({
where: { id: body.deviceId }, where: { id: body.deviceId },
select: {
userId: true,
},
}); });
if (!existing) { if (!existing || existing.userId !== ownerUserId) {
throw new NotFoundException('Device not found'); throw new NotFoundException('Device not found');
} }
@ -67,4 +71,9 @@ export class DevicesService {
serverTime: new Date().toISOString(), serverTime: new Date().toISOString(),
}; };
} }
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
} }

View File

@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DefaultUserService } from '../users/default-user.service'; import { OwnerContext } from '../users/owner-context.service';
import { LibraryService } from './library.service'; import { LibraryService } from './library.service';
function createPrismaMock() { function createPrismaMock() {
@ -55,10 +55,16 @@ function createPrismaMock() {
describe('LibraryService', () => { describe('LibraryService', () => {
let libraryService: LibraryService; let libraryService: LibraryService;
let state: ReturnType<typeof createPrismaMock>['state']; let state: ReturnType<typeof createPrismaMock>['state'];
let ownerContextMock: {
resolve: jest.Mock;
};
beforeEach(async () => { beforeEach(async () => {
const { prismaMock, state: nextState } = createPrismaMock(); const { prismaMock, state: nextState } = createPrismaMock();
state = nextState; state = nextState;
ownerContextMock = {
resolve: jest.fn(),
};
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
providers: [ providers: [
@ -68,8 +74,8 @@ describe('LibraryService', () => {
useValue: prismaMock, useValue: prismaMock,
}, },
{ {
provide: DefaultUserService, provide: OwnerContext,
useValue: {}, useValue: ownerContextMock,
}, },
], ],
}).compile(); }).compile();
@ -77,6 +83,58 @@ describe('LibraryService', () => {
libraryService = moduleRef.get(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 () => { it('returns only tracks owned by the requesting device user in created order', async () => {
const ownerId = randomUUID(); const ownerId = randomUUID();
const otherUserId = randomUUID(); const otherUserId = randomUUID();
@ -89,6 +147,9 @@ describe('LibraryService', () => {
const otherTrackId = randomUUID(); const otherTrackId = randomUUID();
const otherAssetId = randomUUID(); const otherAssetId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
state.audioAssets.set(ownerAssetId, { state.audioAssets.set(ownerAssetId, {
@ -186,6 +247,9 @@ describe('LibraryService', () => {
it('returns an empty library when the user has no remote tracks', async () => { it('returns an empty library when the user has no remote tracks', async () => {
const ownerId = randomUUID(); const ownerId = randomUUID();
const ownerDeviceId = randomUUID(); const ownerDeviceId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
await expect( await expect(
@ -193,15 +257,72 @@ describe('LibraryService', () => {
).resolves.toEqual([]); ).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 });
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),
).resolves.toEqual([]);
});
it('throws for an unknown device', async () => { it('throws for an unknown device', async () => {
ownerContextMock.resolve.mockResolvedValue({
userId: randomUUID(),
});
await expect( await expect(
libraryService.getRemoteLibraryTracks(randomUUID()), libraryService.getRemoteLibraryTracks(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException); ).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,
});
await expect(
libraryService.getRemoteLibraryTracks(foreignDeviceId),
).rejects.toBeInstanceOf(NotFoundException);
});
it('skips tracks without a primary audio asset', async () => { it('skips tracks without a primary audio asset', async () => {
const ownerId = randomUUID(); const ownerId = randomUUID();
const ownerDeviceId = randomUUID(); const ownerDeviceId = randomUUID();
ownerContextMock.resolve.mockResolvedValue({
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
state.tracks.set(randomUUID(), { state.tracks.set(randomUUID(), {

View File

@ -1,21 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LibraryTrackDto } from '../sync/sync.dto'; 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'; import { RemoteLibraryTrackDto } from './library.dto';
@Injectable() @Injectable()
export class LibraryService { export class LibraryService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly defaultUserService: DefaultUserService, private readonly ownerContext: OwnerContext,
) {} ) {}
async getBootstrapTracks(): Promise<LibraryTrackDto[]> { async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); const ownerUserId = await this.resolveCurrentOwnerUserId();
const tracks = await this.prismaService.track.findMany({ const tracks = await this.prismaService.track.findMany({
where: { where: {
userId: defaultUser.id, userId: ownerUserId,
status: 'ACTIVE', status: 'ACTIVE',
}, },
orderBy: { orderBy: {
@ -38,6 +38,7 @@ export class LibraryService {
async getRemoteLibraryTracks( async getRemoteLibraryTracks(
deviceId: string, deviceId: string,
): Promise<RemoteLibraryTrackDto[]> { ): Promise<RemoteLibraryTrackDto[]> {
const ownerUserId = await this.resolveCurrentOwnerUserId();
const device = await this.prismaService.device.findUnique({ const device = await this.prismaService.device.findUnique({
where: { id: deviceId }, where: { id: deviceId },
select: { select: {
@ -45,13 +46,13 @@ export class LibraryService {
}, },
}); });
if (!device) { if (!device || device.userId !== ownerUserId) {
throw new NotFoundException('Device not found'); throw new NotFoundException('Device not found');
} }
const tracks = await this.prismaService.track.findMany({ const tracks = await this.prismaService.track.findMany({
where: { where: {
userId: device.userId, userId: ownerUserId,
status: 'ACTIVE', status: 'ACTIVE',
primaryAudioAssetId: { primaryAudioAssetId: {
not: null, not: null,
@ -117,4 +118,9 @@ export class LibraryService {
]; ];
}); });
} }
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
} }

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

View File

@ -3,10 +3,11 @@ import { readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { UnprocessableEntityException } from '@nestjs/common'; import { NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { UploadSessionStatus } from '@prisma/client'; import { UploadSessionStatus } from '@prisma/client';
import { AppConfigService } from '../config/config.service'; import { AppConfigService } from '../config/config.service';
import { LocalFilesystemStorageService } from '../storage/storage.service'; import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import { UploadsService } from './uploads.service'; import { UploadsService } from './uploads.service';
type MockState = ReturnType<typeof createPrismaMock>['state']; type MockState = ReturnType<typeof createPrismaMock>['state'];
@ -282,6 +283,7 @@ describe('UploadsService', () => {
let storageRoot: string; let storageRoot: string;
let storageService: LocalFilesystemStorageService; let storageService: LocalFilesystemStorageService;
let service: UploadsService; let service: UploadsService;
let ownerContext: OwnerContext;
beforeEach(async () => { beforeEach(async () => {
const mock = createPrismaMock(); const mock = createPrismaMock();
@ -289,10 +291,16 @@ describe('UploadsService', () => {
state = mock.state; state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-')); storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
ownerContext = {
resolve: jest.fn().mockResolvedValue({
userId: state.defaultUser.id,
}),
} as OwnerContext;
service = new UploadsService( service = new UploadsService(
prismaMock, prismaMock,
createAppConfig(storageRoot), createAppConfig(storageRoot),
storageService, storageService,
ownerContext,
); );
}); });
@ -334,6 +342,36 @@ describe('UploadsService', () => {
expect(state.uploadSessions.size).toBe(1); 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 () => { it('rejects an upload whose bytes do not match the prepared sha', async () => {
const device = seedDevice(); const device = seedDevice();
const uploadedBytes = sampleMp3Bytes('wrong-sha-upload'); const uploadedBytes = sampleMp3Bytes('wrong-sha-upload');
@ -401,6 +439,14 @@ describe('UploadsService', () => {
expect(state.audioAssets.size).toBe(1); expect(state.audioAssets.size).toBe(1);
expect(state.libraryEvents.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!); const session = state.uploadSessions.get(response.uploadId!);
expect(session.finalizedAt).toBeInstanceOf(Date); expect(session.finalizedAt).toBeInstanceOf(Date);
expect(session.trackId).toBe(finalizeResponse.trackId); expect(session.trackId).toBe(finalizeResponse.trackId);
@ -550,4 +596,52 @@ describe('UploadsService', () => {
); );
expect(storedArtworkBytes.equals(artworkBytes)).toBe(true); 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 { import {
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnprocessableEntityException, UnprocessableEntityException,
@ -17,6 +18,7 @@ import { extname } from 'node:path';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service'; import { AppConfigService } from '../config/config.service';
import { LocalFilesystemStorageService } from '../storage/storage.service'; import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import { import {
UploadFinalizeArtworkDto, UploadFinalizeArtworkDto,
UploadFinalizeRequestDto, UploadFinalizeRequestDto,
@ -41,6 +43,7 @@ export class UploadsService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly configService: AppConfigService, private readonly configService: AppConfigService,
private readonly storageService: LocalFilesystemStorageService, private readonly storageService: LocalFilesystemStorageService,
private readonly ownerContext: OwnerContext,
) {} ) {}
async prepare( async prepare(
@ -49,18 +52,13 @@ export class UploadsService {
this.assertFileSizeWithinLimit(body.sizeBytes); this.assertFileSizeWithinLimit(body.sizeBytes);
this.assertMp3Filename(body.originalFilename); this.assertMp3Filename(body.originalFilename);
const device = await this.prismaService.device.findUnique({ const ownerUserId = await this.resolveCurrentOwnerUserId();
where: { id: body.deviceId }, await this.getOwnedDeviceOrThrow(body.deviceId, ownerUserId);
});
if (!device) {
throw new NotFoundException('Device not found');
}
const existingAsset = await this.prismaService.audioAsset.findUnique({ const existingAsset = await this.prismaService.audioAsset.findUnique({
where: { where: {
userId_sha256: { userId_sha256: {
userId: device.userId, userId: ownerUserId,
sha256: body.sha256, sha256: body.sha256,
}, },
}, },
@ -71,7 +69,7 @@ export class UploadsService {
const uploadSession = await this.prismaService.uploadSession.create({ const uploadSession = await this.prismaService.uploadSession.create({
data: { data: {
id: uploadId, id: uploadId,
userId: device.userId, userId: ownerUserId,
deviceId: body.deviceId, deviceId: body.deviceId,
trackId: existingAsset.trackId, trackId: existingAsset.trackId,
audioAssetId: existingAsset.id, audioAssetId: existingAsset.id,
@ -98,7 +96,7 @@ export class UploadsService {
const uploadSession = await this.prismaService.uploadSession.create({ const uploadSession = await this.prismaService.uploadSession.create({
data: { data: {
id: uploadId, id: uploadId,
userId: device.userId, userId: ownerUserId,
deviceId: body.deviceId, deviceId: body.deviceId,
expectedSha256: body.sha256, expectedSha256: body.sha256,
originalFilename: body.originalFilename, originalFilename: body.originalFilename,
@ -118,14 +116,21 @@ export class UploadsService {
} }
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> { 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( async uploadFile(
uploadId: string, uploadId: string,
request: Request, request: Request,
): Promise<UploadSessionStatusResponseDto> { ): 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) { if (uploadSession.status === UploadSessionStatus.COMPLETED) {
return this.toStatusResponse(uploadSession); return this.toStatusResponse(uploadSession);
@ -148,7 +153,7 @@ export class UploadsService {
const tempPath = this.storageService.resolve(uploadSession.tempStoragePath); const tempPath = this.storageService.resolve(uploadSession.tempStoragePath);
const finalPath = this.storageService.userAudioAssetPath( const finalPath = this.storageService.userAudioAssetPath(
uploadSession.userId, ownerUserId,
uploadSession.expectedSha256, uploadSession.expectedSha256,
); );
@ -239,13 +244,28 @@ export class UploadsService {
uploadId: string, uploadId: string,
body: UploadFinalizeRequestDto, body: UploadFinalizeRequestDto,
): Promise<UploadFinalizeResponseDto> { ): Promise<UploadFinalizeResponseDto> {
const uploadSession = await this.getUploadSessionOrThrow(uploadId); const ownerUserId = await this.resolveCurrentOwnerUserId();
const uploadSession = await this.getOwnedUploadSessionOrThrow(
uploadId,
ownerUserId,
);
if ( if (
uploadSession.finalizedAt && uploadSession.finalizedAt &&
uploadSession.trackId && uploadSession.trackId &&
uploadSession.audioAssetId uploadSession.audioAssetId
) { ) {
await this.assertOwnedTrackOrThrow(
this.prismaService,
uploadSession.trackId,
ownerUserId,
);
await this.assertOwnedAudioAssetOrThrow(
this.prismaService,
uploadSession.audioAssetId,
ownerUserId,
);
return { return {
trackId: uploadSession.trackId, trackId: uploadSession.trackId,
assetId: uploadSession.audioAssetId, assetId: uploadSession.audioAssetId,
@ -281,19 +301,28 @@ export class UploadsService {
} }
return this.prismaService.$transaction(async (tx) => { return this.prismaService.$transaction(async (tx) => {
const currentSession = await tx.uploadSession.findUnique({ const currentSession = await this.getOwnedUploadSessionOrThrow(
where: { id: uploadId }, uploadId,
}); ownerUserId,
tx,
if (!currentSession) { );
throw new NotFoundException('Upload session not found');
}
if ( if (
currentSession.finalizedAt && currentSession.finalizedAt &&
currentSession.trackId && currentSession.trackId &&
currentSession.audioAssetId currentSession.audioAssetId
) { ) {
await this.assertOwnedTrackOrThrow(
tx,
currentSession.trackId,
ownerUserId,
);
await this.assertOwnedAudioAssetOrThrow(
tx,
currentSession.audioAssetId,
ownerUserId,
);
return { return {
trackId: currentSession.trackId, trackId: currentSession.trackId,
assetId: currentSession.audioAssetId, assetId: currentSession.audioAssetId,
@ -308,7 +337,7 @@ export class UploadsService {
const preparedArtwork = body.artwork const preparedArtwork = body.artwork
? await this.prepareArtworkAssetInput( ? await this.prepareArtworkAssetInput(
currentSession.userId, ownerUserId,
body.artwork, body.artwork,
) )
: null; : null;
@ -316,7 +345,7 @@ export class UploadsService {
let audioAsset = await tx.audioAsset.findUnique({ let audioAsset = await tx.audioAsset.findUnique({
where: { where: {
userId_sha256: { userId_sha256: {
userId: currentSession.userId, userId: ownerUserId,
sha256: currentSession.expectedSha256, sha256: currentSession.expectedSha256,
}, },
}, },
@ -324,20 +353,22 @@ export class UploadsService {
let track = let track =
currentSession.trackId != null currentSession.trackId != null
? await tx.track.findUnique({ where: { id: currentSession.trackId } }) ? await this.findOwnedTrackById(tx, currentSession.trackId, ownerUserId)
: null; : null;
if (currentSession.trackId != null && !track) {
throw new NotFoundException('Track not found');
}
if (!track && audioAsset?.trackId) { if (!track && audioAsset?.trackId) {
track = await tx.track.findUnique({ track = await this.findOwnedTrackById(tx, audioAsset.trackId, ownerUserId);
where: { id: audioAsset.trackId },
});
} }
const createdTrack = !track; const createdTrack = !track;
if (!track) { if (!track) {
track = await tx.track.create({ track = await tx.track.create({
data: { data: {
userId: currentSession.userId, userId: ownerUserId,
title, title,
artist, artist,
album, album,
@ -375,7 +406,7 @@ export class UploadsService {
} else { } else {
audioAsset = await tx.audioAsset.create({ audioAsset = await tx.audioAsset.create({
data: { data: {
userId: currentSession.userId, userId: ownerUserId,
trackId: track.id, trackId: track.id,
sha256: currentSession.expectedSha256, sha256: currentSession.expectedSha256,
storageKey: finalStorageKey, storageKey: finalStorageKey,
@ -402,7 +433,7 @@ export class UploadsService {
? ( ? (
await this.findOrCreateArtworkAsset( await this.findOrCreateArtworkAsset(
tx, tx,
currentSession.userId, ownerUserId,
preparedArtwork, preparedArtwork,
) )
).id ).id
@ -419,7 +450,7 @@ export class UploadsService {
await tx.libraryEvent.create({ await tx.libraryEvent.create({
data: { data: {
userId: currentSession.userId, userId: ownerUserId,
entityType: EntityType.TRACK, entityType: EntityType.TRACK,
entityId: track.id, entityId: track.id,
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED, action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
@ -443,18 +474,85 @@ export class UploadsService {
}); });
} }
private async getUploadSessionOrThrow(uploadId: string): Promise<UploadSession> { private async resolveCurrentOwnerUserId(): Promise<string> {
const uploadSession = await this.prismaService.uploadSession.findUnique({ const owner = await this.ownerContext.resolve();
return owner.userId;
}
private async getOwnedDeviceOrThrow(deviceId: string, ownerUserId: string) {
const device = await this.prismaService.device.findUnique({
where: { id: deviceId },
select: {
id: true,
userId: true,
},
});
if (!device || device.userId !== ownerUserId) {
throw new NotFoundException('Device not found');
}
return device;
}
private async getOwnedUploadSessionOrThrow(
uploadId: string,
ownerUserId: string,
client: Pick<PrismaService, 'uploadSession'> = this.prismaService,
): Promise<UploadSession> {
const uploadSession = await client.uploadSession.findUnique({
where: { id: uploadId }, where: { id: uploadId },
}); });
if (!uploadSession) { if (!uploadSession || uploadSession.userId !== ownerUserId) {
throw new NotFoundException('Upload session not found'); throw new NotFoundException('Upload session not found');
} }
return uploadSession; 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( private toStatusResponse(
uploadSession: Pick< uploadSession: Pick<
UploadSession, 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 { User } from '@prisma/client';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
@Injectable() @Injectable()
export class DefaultUserService { export class DefaultUserService implements OnApplicationBootstrap {
static readonly defaultOwnerSlug = 'default-owner'; static readonly defaultOwnerSlug = 'default-owner';
static readonly defaultOwnerDisplayName = 'Default Owner'; static readonly defaultOwnerDisplayName = 'Default Owner';
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
async onApplicationBootstrap(): Promise<void> {
await this.getOrCreateDefaultUser();
}
async getOrCreateDefaultUser(): Promise<User> { async getOrCreateDefaultUser(): Promise<User> {
return this.prismaService.user.upsert({ return this.prismaService.user.upsert({
where: { where: {

View File

@ -0,0 +1,22 @@
import { randomUUID } from 'node:crypto';
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);
await expect(service.resolve()).resolves.toEqual({
userId: defaultUser.id,
});
expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
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) {
super();
}
async resolve(): Promise<ResolvedOwnerContext> {
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
return {
userId: defaultUser.id,
};
}
}

View File

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

View File

@ -313,6 +313,7 @@ function createPrismaMock() {
describe('Velody API wiring (e2e)', () => { describe('Velody API wiring (e2e)', () => {
let app: NestExpressApplication; let app: NestExpressApplication;
let prismaMock: ReturnType<typeof createPrismaMock>['prismaMock'];
let assetsController: AssetsController; let assetsController: AssetsController;
let artworkController: ArtworkController; let artworkController: ArtworkController;
let healthController: HealthController; let healthController: HealthController;
@ -325,7 +326,9 @@ describe('Velody API wiring (e2e)', () => {
let storageRoot: string; let storageRoot: string;
beforeEach(async () => { beforeEach(async () => {
const { prismaMock, state } = createPrismaMock(); const prismaSetup = createPrismaMock();
prismaMock = prismaSetup.prismaMock;
const { state } = prismaSetup;
prismaState = state; prismaState = state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-')); storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
@ -385,6 +388,23 @@ describe('Velody API wiring (e2e)', () => {
expect(response.version).toBe('0.1.0'); 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 () => { it('registers a device and accepts heartbeat', async () => {
const registerResponse = await devicesController.register({ const registerResponse = await devicesController.register({
platform: 'MACOS', platform: 'MACOS',
@ -394,6 +414,9 @@ describe('Velody API wiring (e2e)', () => {
expect(registerResponse.deviceId).toBeDefined(); expect(registerResponse.deviceId).toBeDefined();
expect(registerResponse.bootstrapToken).toBeDefined(); expect(registerResponse.bootstrapToken).toBeDefined();
expect(prismaState.devices.get(registerResponse.deviceId)?.userId).toBe(
prismaState.defaultUser.id,
);
const heartbeatResponse = await devicesController.heartbeat({ const heartbeatResponse = await devicesController.heartbeat({
deviceId: registerResponse.deviceId, deviceId: registerResponse.deviceId,
@ -403,6 +426,28 @@ describe('Velody API wiring (e2e)', () => {
expect(heartbeatResponse.ok).toBe(true); 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 () => { it('returns sync bootstrap and changes payloads', async () => {
const bootstrapResponse = await syncController.bootstrap(); const bootstrapResponse = await syncController.bootstrap();
const changesResponse = await syncController.changes({ after: '0' }); const changesResponse = await syncController.changes({ after: '0' });
@ -412,6 +457,47 @@ describe('Velody API wiring (e2e)', () => {
expect(changesResponse.nextCursor).toBe('0'); 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 () => { it('downloads audio asset bytes for the owning device user', async () => {
const registerResponse = await devicesController.register({ const registerResponse = await devicesController.register({
platform: 'IPHONE', platform: 'IPHONE',
@ -610,6 +696,36 @@ describe('Velody API wiring (e2e)', () => {
expect(headers.get('content-length')).toBe(String(bytes.length)); 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 () => { it('returns not found when the requested artwork file is missing', async () => {
const registerResponse = await devicesController.register({ const registerResponse = await devicesController.register({
platform: 'IPHONE', platform: 'IPHONE',