diff --git a/backend/openapi/velody.openapi.json b/backend/openapi/velody.openapi.json index 0699b3d..6c624d8 100644 --- a/backend/openapi/velody.openapi.json +++ b/backend/openapi/velody.openapi.json @@ -15,8 +15,9 @@ }, { "name": "deviceId", - "required": true, + "required": false, "in": "query", + "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", "schema": { "format": "uuid", "type": "string" @@ -36,11 +37,62 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "assets" ] } }, + "/api/v1/artwork/{artworkId}/download": { + "get": { + "operationId": "ArtworkController_download_v1", + "parameters": [ + { + "name": "artworkId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "required": false, + "in": "query", + "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "artwork" + ] + } + }, "/api/v1/health": { "get": { "operationId": "HealthController_getHealth_v1", @@ -88,6 +140,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "devices" ] @@ -119,6 +176,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "devices" ] @@ -150,6 +212,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "uploads" ] @@ -180,6 +247,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "uploads" ] @@ -233,6 +305,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "uploads" ] @@ -273,6 +350,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "uploads" ] @@ -284,8 +366,9 @@ "parameters": [ { "name": "deviceId", - "required": true, + "required": false, "in": "query", + "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", "schema": { "format": "uuid", "type": "string" @@ -304,6 +387,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "library" ] @@ -312,7 +400,18 @@ "/api/v1/sync/bootstrap": { "get": { "operationId": "SyncController_bootstrap_v1", - "parameters": [], + "parameters": [ + { + "name": "deviceId", + "required": false, + "in": "query", + "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "description": "", @@ -325,6 +424,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "sync" ] @@ -334,6 +438,16 @@ "get": { "operationId": "SyncController_changes_v1", "parameters": [ + { + "name": "deviceId", + "required": false, + "in": "query", + "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "after", "required": false, @@ -356,6 +470,11 @@ } } }, + "security": [ + { + "bearer": [] + } + ], "tags": [ "sync" ] @@ -464,6 +583,10 @@ "type": "string", "format": "uuid" }, + "deviceAccessToken": { + "type": "string", + "description": "Raw device access token returned only during registration. Store it securely." + }, "bootstrapToken": { "type": "string" }, @@ -474,6 +597,7 @@ }, "required": [ "deviceId", + "deviceAccessToken", "bootstrapToken", "serverTime" ] @@ -483,7 +607,8 @@ "properties": { "deviceId": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided." }, "appVersion": { "type": "string", @@ -491,7 +616,6 @@ } }, "required": [ - "deviceId", "appVersion" ] }, @@ -517,7 +641,8 @@ "properties": { "deviceId": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided." }, "sha256": { "type": "string", @@ -533,7 +658,6 @@ } }, "required": [ - "deviceId", "sha256", "originalFilename", "sizeBytes" @@ -611,6 +735,39 @@ "nextOffset" ] }, + "UploadFinalizeArtworkDto": { + "type": "object", + "properties": { + "dataBase64": { + "type": "string", + "example": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ..." + }, + "sha256": { + "type": "string", + "example": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "mimeType": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png" + ] + }, + "width": { + "type": "number", + "example": 512 + }, + "height": { + "type": "number", + "example": 512 + } + }, + "required": [ + "dataBase64", + "sha256", + "mimeType" + ] + }, "UploadFinalizeRequestDto": { "type": "object", "properties": { @@ -629,6 +786,9 @@ "durationMs": { "type": "number", "example": 245000 + }, + "artwork": { + "$ref": "#/components/schemas/UploadFinalizeArtworkDto" } }, "required": [ @@ -653,6 +813,38 @@ "assetId" ] }, + "RemoteArtworkDto": { + "type": "object", + "properties": { + "artworkId": { + "type": "string", + "format": "uuid" + }, + "sha256": { + "type": "string", + "example": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "mimeType": { + "type": "string", + "example": "image/jpeg" + }, + "width": { + "type": "object", + "example": 512, + "nullable": true + }, + "height": { + "type": "object", + "example": 512, + "nullable": true + } + }, + "required": [ + "artworkId", + "sha256", + "mimeType" + ] + }, "RemoteLibraryTrackDto": { "type": "object", "properties": { @@ -687,6 +879,15 @@ "updatedAt": { "type": "string", "example": "2026-05-29T08:05:00.000Z" + }, + "artwork": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/RemoteArtworkDto" + } + ] } }, "required": [ diff --git a/backend/src/infrastructure/request-context/request-context.middleware.ts b/backend/src/infrastructure/request-context/request-context.middleware.ts index c3c01fa..300f863 100644 --- a/backend/src/infrastructure/request-context/request-context.middleware.ts +++ b/backend/src/infrastructure/request-context/request-context.middleware.ts @@ -6,7 +6,44 @@ import { RequestContextService } from './request-context.service'; export class RequestContextMiddleware implements NestMiddleware { constructor(private readonly requestContext: RequestContextService) {} - use(_request: Request, _response: Response, next: NextFunction): void { - this.requestContext.run(() => next()); + use(request: Request, _response: Response, next: NextFunction): void { + this.requestContext.run(() => { + const legacyDeviceId = this.extractLegacyDeviceId(request); + + if (legacyDeviceId) { + this.requestContext.setLegacyDeviceId(legacyDeviceId); + } + + next(); + }); + } + + private extractLegacyDeviceId(request: Request): string | null { + return ( + this.readDeviceIdCandidate(request.body) ?? + this.readDeviceIdCandidate(request.query) + ); + } + + private readDeviceIdCandidate(source: unknown): string | null { + if (!source || typeof source !== 'object') { + return null; + } + + const rawDeviceId = (source as { deviceId?: unknown }).deviceId; + + if (typeof rawDeviceId === 'string' && rawDeviceId.trim()) { + return rawDeviceId.trim(); + } + + if (!Array.isArray(rawDeviceId)) { + return null; + } + + const firstString = rawDeviceId.find( + (value): value is string => typeof value === 'string' && value.trim().length > 0, + ); + + return firstString?.trim() ?? null; } } diff --git a/backend/src/infrastructure/request-context/request-context.service.ts b/backend/src/infrastructure/request-context/request-context.service.ts index 7059ca2..7bc0dc0 100644 --- a/backend/src/infrastructure/request-context/request-context.service.ts +++ b/backend/src/infrastructure/request-context/request-context.service.ts @@ -8,6 +8,7 @@ export interface AuthenticatedDeviceContextValue { interface RequestContextState { authenticatedDevice: AuthenticatedDeviceContextValue | null; + legacyDeviceId: string | null; } @Injectable() @@ -18,6 +19,7 @@ export class RequestContextService { return this.storage.run( { authenticatedDevice: null, + legacyDeviceId: null, }, callback, ); @@ -36,4 +38,18 @@ export class RequestContextService { store.authenticatedDevice = device; } + + getLegacyDeviceId(): string | null { + return this.storage.getStore()?.legacyDeviceId ?? null; + } + + setLegacyDeviceId(deviceId: string): void { + const store = this.storage.getStore(); + + if (!store) { + return; + } + + store.legacyDeviceId = deviceId; + } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 83788ac..3f43057 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -2,12 +2,11 @@ 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], + imports: [PrismaModule, RequestContextModule], providers: [DeviceAuthService, DeviceAuthGuard], exports: [DeviceAuthService, DeviceAuthGuard], }) diff --git a/backend/src/modules/auth/device-auth.service.spec.ts b/backend/src/modules/auth/device-auth.service.spec.ts index e0d9a33..fac2eba 100644 --- a/backend/src/modules/auth/device-auth.service.spec.ts +++ b/backend/src/modules/auth/device-auth.service.spec.ts @@ -5,7 +5,6 @@ import { 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', () => { @@ -13,9 +12,6 @@ describe('DeviceAuthService', () => { const userId = randomUUID(); const deviceId = randomUUID(); const requestContext = new RequestContextService(); - const ownerContext = { - resolve: jest.fn().mockResolvedValue({ userId }), - } as OwnerContext; const service = new DeviceAuthService( { device: { @@ -28,7 +24,6 @@ describe('DeviceAuthService', () => { }, } as any, requestContext, - ownerContext, ); const token = service.generateDeviceAccessToken(); @@ -63,9 +58,6 @@ describe('DeviceAuthService', () => { }, } as any, requestContext, - { - resolve: jest.fn(), - } as any, ); await expect( @@ -83,11 +75,8 @@ describe('DeviceAuthService', () => { 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 missingDeviceId = randomUUID(); const requestContext = new RequestContextService(); - const ownerContext = { - resolve: jest.fn().mockResolvedValue({ userId }), - } as any; const service = new DeviceAuthService( { device: { @@ -99,27 +88,19 @@ describe('DeviceAuthService', () => { }; } - 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)), + requestContext.run(() => service.resolveCurrentDevice(missingDeviceId)), ).rejects.toBeInstanceOf(NotFoundException); await expect( requestContext.run(() => service.resolveCurrentDevice(deviceId)), @@ -128,4 +109,32 @@ describe('DeviceAuthService', () => { userId, }); }); + + it('uses the request-context legacy device id when the caller does not pass one explicitly', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + const requestContext = new RequestContextService(); + const service = new DeviceAuthService( + { + device: { + findUnique: jest.fn().mockResolvedValue({ + id: deviceId, + userId, + }), + update: jest.fn(), + }, + } as any, + requestContext, + ); + + await expect( + requestContext.run(async () => { + requestContext.setLegacyDeviceId(deviceId); + return service.resolveCurrentDevice(); + }), + ).resolves.toEqual({ + deviceId, + userId, + }); + }); }); diff --git a/backend/src/modules/auth/device-auth.service.ts b/backend/src/modules/auth/device-auth.service.ts index 85c71b2..ae2e439 100644 --- a/backend/src/modules/auth/device-auth.service.ts +++ b/backend/src/modules/auth/device-auth.service.ts @@ -10,14 +10,15 @@ import { AuthenticatedDeviceContextValue, RequestContextService, } from '../../infrastructure/request-context/request-context.service'; -import { OwnerContext } from '../users/owner-context.service'; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @Injectable() export class DeviceAuthService { constructor( private readonly prismaService: PrismaService, private readonly requestContext: RequestContextService, - private readonly ownerContext: OwnerContext, ) {} generateDeviceAccessToken(): string { @@ -76,16 +77,22 @@ export class DeviceAuthService { return authenticatedDevice; } - if (!legacyDeviceId) { + const requestedDeviceId = + legacyDeviceId ?? this.requestContext.getLegacyDeviceId(); + + if (!requestedDeviceId) { throw new BadRequestException( 'deviceId is required when Authorization is missing.', ); } - const owner = await this.ownerContext.resolve(); + if (!UUID_PATTERN.test(requestedDeviceId)) { + throw new BadRequestException('deviceId must be a UUID'); + } + const device = await this.prismaService.device.findUnique({ where: { - id: legacyDeviceId, + id: requestedDeviceId, }, select: { id: true, @@ -93,7 +100,7 @@ export class DeviceAuthService { }, }); - if (!device || device.userId !== owner.userId) { + if (!device) { throw new NotFoundException('Device not found'); } diff --git a/backend/src/modules/devices/devices.controller.ts b/backend/src/modules/devices/devices.controller.ts index cbaed4a..42ccdd0 100644 --- a/backend/src/modules/devices/devices.controller.ts +++ b/backend/src/modules/devices/devices.controller.ts @@ -23,6 +23,8 @@ export class DevicesController { constructor(private readonly devicesService: DevicesService) {} @Post('register') + @UseGuards(DeviceAuthGuard) + @ApiBearerAuth() @ApiCreatedResponse({ type: RegisterDeviceResponseDto }) async register( @Body() body: RegisterDeviceRequestDto, diff --git a/backend/src/modules/sync/sync.controller.ts b/backend/src/modules/sync/sync.controller.ts index 2a042b9..2413a83 100644 --- a/backend/src/modules/sync/sync.controller.ts +++ b/backend/src/modules/sync/sync.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { DeviceAuthGuard } from '../auth/device-auth.guard'; import { + SyncBootstrapQueryDto, SyncBootstrapResponseDto, SyncChangesQueryDto, SyncChangesResponseDto, @@ -20,7 +21,9 @@ export class SyncController { @Get('bootstrap') @ApiOkResponse({ type: SyncBootstrapResponseDto }) - async bootstrap(): Promise { + async bootstrap( + @Query() _query?: SyncBootstrapQueryDto, + ): Promise { return this.syncService.bootstrap(); } diff --git a/backend/src/modules/sync/sync.dto.ts b/backend/src/modules/sync/sync.dto.ts index f18ad43..b23ba13 100644 --- a/backend/src/modules/sync/sync.dto.ts +++ b/backend/src/modules/sync/sync.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, Matches } from 'class-validator'; +import { IsOptional, IsString, IsUUID, Matches } from 'class-validator'; export class LibraryTrackDto { @ApiProperty({ format: 'uuid', required: false }) @@ -43,7 +43,19 @@ export class SyncBootstrapResponseDto { serverTime!: string; } -export class SyncChangesQueryDto { +export class SyncBootstrapQueryDto { + @ApiProperty({ + format: 'uuid', + required: false, + description: + 'Legacy migration fallback. Omit when Authorization: Bearer is provided.', + }) + @IsOptional() + @IsUUID() + deviceId?: string; +} + +export class SyncChangesQueryDto extends SyncBootstrapQueryDto { @ApiProperty({ required: false, example: '0' }) @IsOptional() @IsString() diff --git a/backend/src/modules/users/owner-context.service.spec.ts b/backend/src/modules/users/owner-context.service.spec.ts index f2a427e..300d825 100644 --- a/backend/src/modules/users/owner-context.service.spec.ts +++ b/backend/src/modules/users/owner-context.service.spec.ts @@ -16,6 +16,11 @@ describe('BootstrapOwnerContextService', () => { const service = new BootstrapOwnerContextService( defaultUserService, new RequestContextService(), + { + device: { + findUnique: jest.fn(), + }, + } as any, ); await expect(service.resolve()).resolves.toEqual({ @@ -29,13 +34,23 @@ describe('BootstrapOwnerContextService', () => { const defaultUserService = { getOrCreateDefaultUser: jest.fn(), } as any; + const legacyOwnerId = randomUUID(); + const prismaService = { + device: { + findUnique: jest.fn().mockResolvedValue({ + userId: legacyOwnerId, + }), + }, + } as any; const service = new BootstrapOwnerContextService( defaultUserService, requestContext, + prismaService, ); const authenticatedOwnerId = randomUUID(); await requestContext.run(async () => { + requestContext.setLegacyDeviceId(randomUUID()); requestContext.setAuthenticatedDevice({ deviceId: randomUUID(), userId: authenticatedOwnerId, @@ -47,5 +62,45 @@ describe('BootstrapOwnerContextService', () => { }); expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled(); + expect(prismaService.device.findUnique).not.toHaveBeenCalled(); + }); + + it('falls back to the legacy device owner before the bootstrap default owner', async () => { + const requestContext = new RequestContextService(); + const defaultUserService = { + getOrCreateDefaultUser: jest.fn(), + } as any; + const legacyDeviceId = randomUUID(); + const legacyOwnerId = randomUUID(); + const prismaService = { + device: { + findUnique: jest.fn().mockResolvedValue({ + userId: legacyOwnerId, + }), + }, + } as any; + const service = new BootstrapOwnerContextService( + defaultUserService, + requestContext, + prismaService, + ); + + await requestContext.run(async () => { + requestContext.setLegacyDeviceId(legacyDeviceId); + + await expect(service.resolve()).resolves.toEqual({ + userId: legacyOwnerId, + }); + }); + + expect(prismaService.device.findUnique).toHaveBeenCalledWith({ + where: { + id: legacyDeviceId, + }, + select: { + userId: true, + }, + }); + expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled(); }); }); diff --git a/backend/src/modules/users/owner-context.service.ts b/backend/src/modules/users/owner-context.service.ts index 240c863..ce8cfdd 100644 --- a/backend/src/modules/users/owner-context.service.ts +++ b/backend/src/modules/users/owner-context.service.ts @@ -1,4 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; import { RequestContextService } from '../../infrastructure/request-context/request-context.service'; import { DefaultUserService } from './default-user.service'; @@ -10,11 +15,15 @@ export abstract class OwnerContext { abstract resolve(): Promise; } +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + @Injectable() export class BootstrapOwnerContextService extends OwnerContext { constructor( private readonly defaultUserService: DefaultUserService, private readonly requestContext: RequestContextService, + private readonly prismaService: PrismaService, ) { super(); } @@ -28,6 +37,31 @@ export class BootstrapOwnerContextService extends OwnerContext { }; } + const legacyDeviceId = this.requestContext.getLegacyDeviceId(); + + if (legacyDeviceId) { + if (!UUID_PATTERN.test(legacyDeviceId)) { + throw new BadRequestException('deviceId must be a UUID'); + } + + const legacyDevice = await this.prismaService.device.findUnique({ + where: { + id: legacyDeviceId, + }, + select: { + userId: true, + }, + }); + + if (!legacyDevice) { + throw new NotFoundException('Device not found'); + } + + return { + userId: legacyDevice.userId, + }; + } + const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); return { diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index ec22ff4..455c65e 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -353,6 +353,71 @@ describe('Velody API wiring (e2e)', () => { let prismaState: ReturnType['state']; let storageRoot: string; + async function registerDeviceWithAuthorization( + body: { + platform: 'MACOS' | 'IPHONE'; + deviceName: string; + appVersion: string; + }, + authorizationHeader: string, + ) { + return requestContextService.run(async () => { + await deviceAuthService.authenticateAuthorizationHeader( + authorizationHeader, + ); + + return devicesController.register(body); + }); + } + + function seedDevice(params: { + userId: string; + deviceAccessToken?: string; + deviceId?: string; + deviceName?: string; + appVersion?: string; + platform?: 'MACOS' | 'IPHONE'; + tokenRevokedAt?: Date | null; + }) { + const deviceId = params.deviceId ?? randomUUID(); + const deviceAccessToken = params.deviceAccessToken; + + prismaState.devices.set(deviceId, { + id: deviceId, + userId: params.userId, + platform: params.platform ?? 'IPHONE', + deviceName: params.deviceName ?? 'Seeded Device', + appVersion: params.appVersion ?? '0.1.0', + installTokenHash: `seeded-install-${deviceId}`, + tokenHash: deviceAccessToken + ? sha256Hex(Buffer.from(deviceAccessToken, 'utf8')) + : undefined, + tokenCreatedAt: deviceAccessToken ? new Date() : undefined, + tokenRevokedAt: params.tokenRevokedAt ?? undefined, + lastSeenAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + + return { + deviceId, + deviceAccessToken, + }; + } + + async function runAsDevice( + deviceAccessToken: string, + callback: () => Promise, + ): Promise { + return requestContextService.run(async () => { + await deviceAuthService.authenticateAuthorizationHeader( + `Bearer ${deviceAccessToken}`, + ); + + return callback(); + }); + } + beforeEach(async () => { const prismaSetup = createPrismaMock(); prismaMock = prismaSetup.prismaMock; @@ -463,7 +528,68 @@ describe('Velody API wiring (e2e)', () => { expect(heartbeatResponse.ok).toBe(true); }); - it('rejects heartbeat updates for a foreign-owner device', async () => { + it('registers a linked device under the authenticated device owner when Authorization is present', async () => { + const linkedOwnerId = randomUUID(); + const existingDevice = seedDevice({ + userId: linkedOwnerId, + deviceAccessToken: 'linked-owner-access-token', + deviceName: 'Existing Linked Owner Device', + platform: 'MACOS', + }); + + const response = await registerDeviceWithAuthorization( + { + platform: 'IPHONE', + deviceName: 'Linked iPhone', + appVersion: '0.1.0', + }, + `Bearer ${existingDevice.deviceAccessToken}`, + ); + + expect(response.deviceId).toBeDefined(); + expect(response.deviceAccessToken).toBeDefined(); + expect(prismaState.devices.get(response.deviceId)?.userId).toBe( + linkedOwnerId, + ); + expect(prismaState.devices.get(response.deviceId)?.userId).not.toBe( + prismaState.defaultUser.id, + ); + }); + + it('returns 401 when register receives an invalid Authorization header', async () => { + await expect( + registerDeviceWithAuthorization( + { + platform: 'IPHONE', + deviceName: 'Rejected iPhone', + appVersion: '0.1.0', + }, + 'Bearer invalid-device-token', + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects linked registration when the authenticating device token has been revoked', async () => { + const revokedDevice = seedDevice({ + userId: randomUUID(), + deviceAccessToken: 'revoked-link-token', + tokenRevokedAt: new Date('2026-06-09T10:00:00.000Z'), + deviceName: 'Revoked Device', + }); + + await expect( + registerDeviceWithAuthorization( + { + platform: 'MACOS', + deviceName: 'Blocked Mac', + appVersion: '0.1.0', + }, + `Bearer ${revokedDevice.deviceAccessToken}`, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('accepts heartbeat updates for a legacy device id outside the bootstrap owner', async () => { const foreignDeviceId = randomUUID(); prismaState.devices.set(foreignDeviceId, { id: foreignDeviceId, @@ -477,12 +603,13 @@ describe('Velody API wiring (e2e)', () => { updatedAt: new Date(), }); - await expect( - devicesController.heartbeat({ - deviceId: foreignDeviceId, - appVersion: '0.1.1', - }), - ).rejects.toBeInstanceOf(NotFoundException); + const response = await devicesController.heartbeat({ + deviceId: foreignDeviceId, + appVersion: '0.1.1', + }); + + expect(response.ok).toBe(true); + expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.1'); }); it('returns sync bootstrap and changes payloads', async () => { @@ -1138,6 +1265,81 @@ describe('Velody API wiring (e2e)', () => { }); }); + it('lets linked devices under the same identity see the same library', async () => { + const identityUserId = randomUUID(); + const primaryDevice = seedDevice({ + userId: identityUserId, + deviceAccessToken: 'shared-library-primary-token', + deviceName: 'Identity Mac', + platform: 'MACOS', + }); + const linkResponse = await registerDeviceWithAuthorization( + { + platform: 'IPHONE', + deviceName: 'Identity iPhone', + appVersion: '0.1.0', + }, + `Bearer ${primaryDevice.deviceAccessToken}`, + ); + const linkedDeviceToken = linkResponse.deviceAccessToken; + const linkedDeviceId = linkResponse.deviceId; + const trackId = randomUUID(); + const assetId = randomUUID(); + + prismaState.audioAssets.set(assetId, { + id: assetId, + userId: identityUserId, + trackId, + sha256: 'shared-library-sha', + storageKey: `users/${identityUserId}/audio/shared-library-sha.mp3`, + originalFilename: 'shared-library.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: BigInt(42), + durationMs: 198000, + sourceDeviceId: primaryDevice.deviceId, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + }); + prismaState.tracks.set(trackId, { + id: trackId, + userId: identityUserId, + primaryAudioAssetId: assetId, + artworkAssetId: null, + title: 'Shared Library Track', + artist: 'Velody', + album: null, + albumArtist: null, + genre: null, + discNumber: null, + trackNumber: null, + year: null, + durationMs: 198000, + status: 'ACTIVE', + deletedAt: null, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + updatedAt: new Date('2026-05-29T08:02:00.000Z'), + }); + + const primaryLibrary = await runAsDevice( + primaryDevice.deviceAccessToken!, + () => libraryController.getTracks({}), + ); + const linkedLibrary = await runAsDevice(linkedDeviceToken, () => + libraryController.getTracks({ + deviceId: linkedDeviceId, + }), + ); + + expect(primaryLibrary.tracks).toEqual([ + expect.objectContaining({ + trackId, + assetId, + title: 'Shared Library Track', + }), + ]); + expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks); + }); + it('keeps the legacy library deviceId path working when Authorization is missing', async () => { const ownerDevice = await devicesController.register({ platform: 'IPHONE', @@ -1321,6 +1523,69 @@ describe('Velody API wiring (e2e)', () => { ); }); + it('makes an upload from one device visible to another linked device under the same owner', async () => { + const identityUserId = randomUUID(); + const primaryDevice = seedDevice({ + userId: identityUserId, + deviceAccessToken: 'linked-upload-primary-token', + deviceName: 'Upload Mac', + platform: 'MACOS', + }); + const linkResponse = await registerDeviceWithAuthorization( + { + platform: 'IPHONE', + deviceName: 'Upload iPhone', + appVersion: '0.1.0', + }, + `Bearer ${primaryDevice.deviceAccessToken}`, + ); + const linkedDeviceToken = linkResponse.deviceAccessToken; + const bytes = sampleMp3Bytes('linked-upload'); + const sha256 = sha256Hex(bytes); + + const prepareResponse = await runAsDevice( + primaryDevice.deviceAccessToken!, + () => + uploadsController.prepare({ + sha256, + originalFilename: 'linked-upload.mp3', + sizeBytes: bytes.length, + }), + ); + + expect(prepareResponse.status).toBe('upload_required'); + expect(prepareResponse.uploadId).toBeDefined(); + + await runAsDevice(primaryDevice.deviceAccessToken!, () => + uploadsController.uploadFile( + prepareResponse.uploadId!, + createUploadRequest(bytes), + ), + ); + + const finalizeResponse = await runAsDevice( + primaryDevice.deviceAccessToken!, + () => + uploadsController.finalize(prepareResponse.uploadId!, { + title: 'Linked Upload Track', + artist: 'Velody', + durationMs: 123000, + }), + ); + + const linkedLibrary = await runAsDevice(linkedDeviceToken, () => + libraryController.getTracks({}), + ); + + expect(linkedLibrary.tracks).toEqual([ + expect.objectContaining({ + trackId: finalizeResponse.trackId, + assetId: finalizeResponse.assetId, + title: 'Linked Upload Track', + }), + ]); + }); + it('supports the MP3 upload pipeline through the Nest app wiring', async () => { const registerResponse = await devicesController.register({ platform: 'MACOS',