Add multi-device identity foundation

This commit is contained in:
diyaa 2026-06-10 10:50:46 +02:00
parent 45c270c187
commit 8902efb92e
12 changed files with 689 additions and 49 deletions

View File

@ -15,8 +15,9 @@
},
{
"name": "deviceId",
"required": true,
"required": false,
"in": "query",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> 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 <deviceAccessToken> 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 <deviceAccessToken> 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 <deviceAccessToken> 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 <deviceAccessToken> 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 <deviceAccessToken> 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 <deviceAccessToken> 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": [

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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],
})

View File

@ -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,
});
});
});

View File

@ -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');
}

View File

@ -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,

View File

@ -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<SyncBootstrapResponseDto> {
async bootstrap(
@Query() _query?: SyncBootstrapQueryDto,
): Promise<SyncBootstrapResponseDto> {
return this.syncService.bootstrap();
}

View File

@ -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 <deviceAccessToken> is provided.',
})
@IsOptional()
@IsUUID()
deviceId?: string;
}
export class SyncChangesQueryDto extends SyncBootstrapQueryDto {
@ApiProperty({ required: false, example: '0' })
@IsOptional()
@IsString()

View File

@ -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();
});
});

View File

@ -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<ResolvedOwnerContext>;
}
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 {

View File

@ -353,6 +353,71 @@ describe('Velody API wiring (e2e)', () => {
let prismaState: ReturnType<typeof createPrismaMock>['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<T>(
deviceAccessToken: string,
callback: () => Promise<T>,
): Promise<T> {
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',