Add multi-device identity foundation
This commit is contained in:
parent
45c270c187
commit
8902efb92e
@ -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": [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user