diff --git a/backend/prisma/migrations/20260623110000_milestone112_account_foundations/migration.sql b/backend/prisma/migrations/20260623110000_milestone112_account_foundations/migration.sql new file mode 100644 index 0000000..fdaaefc --- /dev/null +++ b/backend/prisma/migrations/20260623110000_milestone112_account_foundations/migration.sql @@ -0,0 +1,188 @@ +CREATE TYPE "UserAccountKind" AS ENUM ('LEGACY_DEFAULT', 'GUEST', 'REGISTERED'); +CREATE TYPE "UserAccountStatus" AS ENUM ('ACTIVE', 'LOCKED', 'DELETED'); + +ALTER TABLE "users" +ADD COLUMN "account_kind" "UserAccountKind", +ADD COLUMN "account_status" "UserAccountStatus", +ADD COLUMN "library_namespace" UUID; + +UPDATE "users" +SET + "account_kind" = 'LEGACY_DEFAULT'::"UserAccountKind", + "account_status" = 'ACTIVE'::"UserAccountStatus", + "library_namespace" = COALESCE("library_namespace", gen_random_uuid()) +WHERE "account_kind" IS NULL + OR "account_status" IS NULL + OR "library_namespace" IS NULL; + +UPDATE "users" +SET + "account_kind" = 'LEGACY_DEFAULT'::"UserAccountKind", + "account_status" = 'ACTIVE'::"UserAccountStatus" +WHERE "slug" = 'default-owner'; + +ALTER TABLE "users" +ALTER COLUMN "account_kind" SET NOT NULL, +ALTER COLUMN "account_kind" SET DEFAULT 'LEGACY_DEFAULT'::"UserAccountKind", +ALTER COLUMN "account_status" SET NOT NULL, +ALTER COLUMN "account_status" SET DEFAULT 'ACTIVE'::"UserAccountStatus", +ALTER COLUMN "library_namespace" SET NOT NULL; + +CREATE UNIQUE INDEX "users_library_namespace_key" +ON "users"("library_namespace"); + +CREATE TABLE "user_password_credentials" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "password_hash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "user_password_credentials_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_password_credentials_user_id_key" +ON "user_password_credentials"("user_id"); + +CREATE TABLE "user_oauth_identities" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "provider_subject" TEXT NOT NULL, + "email" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "user_oauth_identities_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_oauth_identities_provider_provider_subject_key" +ON "user_oauth_identities"("provider", "provider_subject"); + +CREATE INDEX "user_oauth_identities_user_id_idx" +ON "user_oauth_identities"("user_id"); + +CREATE TABLE "email_verification_tokens" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "email" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "email_verification_tokens_token_hash_key" +ON "email_verification_tokens"("token_hash"); + +CREATE INDEX "email_verification_tokens_user_id_idx" +ON "email_verification_tokens"("user_id"); + +CREATE TABLE "password_reset_tokens" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key" +ON "password_reset_tokens"("token_hash"); + +CREATE INDEX "password_reset_tokens_user_id_idx" +ON "password_reset_tokens"("user_id"); + +CREATE TABLE "device_link_history" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "device_id" UUID, + "linked_by_device_id" UUID, + "link_method" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "device_link_history_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "device_link_history_user_id_idx" +ON "device_link_history"("user_id"); + +CREATE INDEX "device_link_history_device_id_idx" +ON "device_link_history"("device_id"); + +CREATE TABLE "ownership_transfers" ( + "id" UUID NOT NULL, + "from_user_id" UUID NOT NULL, + "to_user_id" UUID, + "requested_by_device_id" UUID, + "status" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "completed_at" TIMESTAMP(3), + CONSTRAINT "ownership_transfers_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ownership_transfers_from_user_id_idx" +ON "ownership_transfers"("from_user_id"); + +CREATE INDEX "ownership_transfers_to_user_id_idx" +ON "ownership_transfers"("to_user_id"); + +ALTER TABLE "user_password_credentials" +ADD CONSTRAINT "user_password_credentials_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "user_oauth_identities" +ADD CONSTRAINT "user_oauth_identities_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "email_verification_tokens" +ADD CONSTRAINT "email_verification_tokens_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "password_reset_tokens" +ADD CONSTRAINT "password_reset_tokens_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "device_link_history" +ADD CONSTRAINT "device_link_history_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "device_link_history" +ADD CONSTRAINT "device_link_history_device_id_fkey" +FOREIGN KEY ("device_id") REFERENCES "devices"("id") +ON DELETE SET NULL +ON UPDATE CASCADE; + +ALTER TABLE "device_link_history" +ADD CONSTRAINT "device_link_history_linked_by_device_id_fkey" +FOREIGN KEY ("linked_by_device_id") REFERENCES "devices"("id") +ON DELETE SET NULL +ON UPDATE CASCADE; + +ALTER TABLE "ownership_transfers" +ADD CONSTRAINT "ownership_transfers_from_user_id_fkey" +FOREIGN KEY ("from_user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "ownership_transfers" +ADD CONSTRAINT "ownership_transfers_to_user_id_fkey" +FOREIGN KEY ("to_user_id") REFERENCES "users"("id") +ON DELETE SET NULL +ON UPDATE CASCADE; + +ALTER TABLE "ownership_transfers" +ADD CONSTRAINT "ownership_transfers_requested_by_device_id_fkey" +FOREIGN KEY ("requested_by_device_id") REFERENCES "devices"("id") +ON DELETE SET NULL +ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 898696c..1aa99c7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,42 +8,55 @@ datasource db { } model User { - id String @id @default(uuid()) @db.Uuid - slug String @unique - displayName String @map("display_name") - isDefault Boolean @default(false) @map("is_default") - libraryCursor BigInt @default(0) @map("library_cursor") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - devices Device[] - tracks Track[] - audioAssets AudioAsset[] - artworkAssets ArtworkAsset[] - uploadSessions UploadSession[] - libraryEvents LibraryEvent[] - syncCursors DeviceSyncCursor[] + id String @id @default(uuid()) @db.Uuid + slug String @unique + displayName String @map("display_name") + isDefault Boolean @default(false) @map("is_default") + accountKind UserAccountKind @default(LEGACY_DEFAULT) @map("account_kind") + accountStatus UserAccountStatus @default(ACTIVE) @map("account_status") + libraryNamespace String @unique @default(uuid()) @map("library_namespace") @db.Uuid + libraryCursor BigInt @default(0) @map("library_cursor") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + devices Device[] + tracks Track[] + audioAssets AudioAsset[] + artworkAssets ArtworkAsset[] + uploadSessions UploadSession[] + libraryEvents LibraryEvent[] + syncCursors DeviceSyncCursor[] + passwordCredential UserPasswordCredential? + oauthIdentities UserOAuthIdentity[] + emailVerificationTokens EmailVerificationToken[] + passwordResetTokens PasswordResetToken[] + deviceLinkHistory DeviceLinkHistory[] + outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser") + incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser") @@map("users") } model Device { - id String @id @default(uuid()) @db.Uuid - userId String @db.Uuid @map("user_id") - platform DevicePlatform - deviceName String @map("device_name") - appVersion String @map("app_version") - installTokenHash String @map("install_token_hash") - tokenHash String? @unique @map("token_hash") - tokenCreatedAt DateTime? @map("token_created_at") - tokenLastUsedAt DateTime? @map("token_last_used_at") - tokenRevokedAt DateTime? @map("token_revoked_at") - lastSeenAt DateTime @map("last_seen_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - uploadSessions UploadSession[] - syncCursor DeviceSyncCursor? - audioAssets AudioAsset[] - user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + platform DevicePlatform + deviceName String @map("device_name") + appVersion String @map("app_version") + installTokenHash String @map("install_token_hash") + tokenHash String? @unique @map("token_hash") + tokenCreatedAt DateTime? @map("token_created_at") + tokenLastUsedAt DateTime? @map("token_last_used_at") + tokenRevokedAt DateTime? @map("token_revoked_at") + lastSeenAt DateTime @map("last_seen_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + uploadSessions UploadSession[] + syncCursor DeviceSyncCursor? + audioAssets AudioAsset[] + linkHistory DeviceLinkHistory[] @relation("DeviceLinkHistoryDevice") + linkedByHistory DeviceLinkHistory[] @relation("DeviceLinkHistoryLinkedByDevice") + requestedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByDevice") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@index([userId]) @@map("devices") @@ -51,9 +64,9 @@ model Device { model Track { id String @id @default(uuid()) @db.Uuid - userId String @db.Uuid @map("user_id") - primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id") - artworkAssetId String? @db.Uuid @map("artwork_asset_id") + userId String @map("user_id") @db.Uuid + primaryAudioAssetId String? @unique @map("primary_audio_asset_id") @db.Uuid + artworkAssetId String? @map("artwork_asset_id") @db.Uuid title String artist String album String? @@ -78,8 +91,8 @@ model Track { model AudioAsset { id String @id @default(uuid()) @db.Uuid - userId String @db.Uuid @map("user_id") - trackId String? @db.Uuid @map("track_id") + userId String @map("user_id") @db.Uuid + trackId String? @map("track_id") @db.Uuid sha256 String storageKey String @unique @map("storage_key") originalFilename String @map("original_filename") @@ -90,7 +103,7 @@ model AudioAsset { sampleRateHz Int? @map("sample_rate_hz") channels Int? durationMs Int? @map("duration_ms") - sourceDeviceId String? @db.Uuid @map("source_device_id") + sourceDeviceId String? @map("source_device_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade) primaryForTrack Track? @relation("PrimaryAudioAsset") @@ -103,7 +116,7 @@ model AudioAsset { } model ArtworkAsset { - userId String @db.Uuid @map("user_id") + userId String @map("user_id") @db.Uuid id String @id @default(uuid()) @db.Uuid sha256 String storageKey String @unique @map("storage_key") @@ -122,10 +135,10 @@ model ArtworkAsset { model UploadSession { id String @id @default(uuid()) @db.Uuid - userId String @db.Uuid @map("user_id") - deviceId String @db.Uuid @map("device_id") - trackId String? @db.Uuid @map("track_id") - audioAssetId String? @db.Uuid @map("audio_asset_id") + userId String @map("user_id") @db.Uuid + deviceId String @map("device_id") @db.Uuid + trackId String? @map("track_id") @db.Uuid + audioAssetId String? @map("audio_asset_id") @db.Uuid expectedSha256 String @map("expected_sha256") originalFilename String @map("original_filename") expectedSizeBytes BigInt @map("expected_size_bytes") @@ -146,34 +159,133 @@ model UploadSession { model LibraryEvent { id BigInt @id @default(autoincrement()) - userId String @db.Uuid @map("user_id") + userId String @map("user_id") @db.Uuid cursor BigInt entityType EntityType @map("entity_type") - entityId String @db.Uuid @map("entity_id") + entityId String @map("entity_id") @db.Uuid action EventAction payload Json payloadVersion Int @default(1) @map("payload_version") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) - @@index([userId]) @@unique([userId, cursor]) + @@index([userId]) @@index([userId, cursor]) @@map("library_events") } model DeviceSyncCursor { - deviceId String @id @db.Uuid @map("device_id") - userId String @db.Uuid @map("user_id") - cursor BigInt @default(0) - updatedAt DateTime @updatedAt @map("updated_at") - device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + deviceId String @id @map("device_id") @db.Uuid + userId String @map("user_id") @db.Uuid + cursor BigInt @default(0) + updatedAt DateTime @updatedAt @map("updated_at") + device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@index([userId]) @@map("device_sync_cursors") } +model UserPasswordCredential { + id String @id @default(uuid()) @db.Uuid + userId String @unique @map("user_id") @db.Uuid + passwordHash String @map("password_hash") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@map("user_password_credentials") +} + +model UserOAuthIdentity { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + provider String + providerSubject String @map("provider_subject") + email String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@unique([provider, providerSubject]) + @@index([userId]) + @@map("user_oauth_identities") +} + +model EmailVerificationToken { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + email String + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + createdAt DateTime @default(now()) @map("created_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@index([userId]) + @@map("email_verification_tokens") +} + +model PasswordResetToken { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + createdAt DateTime @default(now()) @map("created_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@index([userId]) + @@map("password_reset_tokens") +} + +model DeviceLinkHistory { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + deviceId String? @map("device_id") @db.Uuid + linkedByDeviceId String? @map("linked_by_device_id") @db.Uuid + linkMethod String @map("link_method") + createdAt DateTime @default(now()) @map("created_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + device Device? @relation("DeviceLinkHistoryDevice", fields: [deviceId], references: [id], onDelete: SetNull, onUpdate: Cascade) + linkedByDevice Device? @relation("DeviceLinkHistoryLinkedByDevice", fields: [linkedByDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade) + + @@index([userId]) + @@index([deviceId]) + @@map("device_link_history") +} + +model OwnershipTransfer { + id String @id @default(uuid()) @db.Uuid + fromUserId String @map("from_user_id") @db.Uuid + toUserId String? @map("to_user_id") @db.Uuid + requestedByDeviceId String? @map("requested_by_device_id") @db.Uuid + status String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + completedAt DateTime? @map("completed_at") + fromUser User @relation("OwnershipTransferFromUser", fields: [fromUserId], references: [id], onDelete: Cascade, onUpdate: Cascade) + toUser User? @relation("OwnershipTransferToUser", fields: [toUserId], references: [id], onDelete: SetNull, onUpdate: Cascade) + requestedByDevice Device? @relation("OwnershipTransferRequestedByDevice", fields: [requestedByDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade) + + @@index([fromUserId]) + @@index([toUserId]) + @@map("ownership_transfers") +} + +enum UserAccountKind { + LEGACY_DEFAULT + GUEST + REGISTERED +} + +enum UserAccountStatus { + ACTIVE + LOCKED + DELETED +} + enum DevicePlatform { MACOS IPHONE diff --git a/backend/src/modules/auth/protected-device-auth.middleware.spec.ts b/backend/src/modules/auth/protected-device-auth.middleware.spec.ts index b1ad21a..ed8321f 100644 --- a/backend/src/modules/auth/protected-device-auth.middleware.spec.ts +++ b/backend/src/modules/auth/protected-device-auth.middleware.spec.ts @@ -23,6 +23,7 @@ describe('ProtectedDeviceAuthMiddleware', () => { '/api/v1/uploads/upload-id/file', '/api/v1/uploads/upload-id/finalize', '/api/v1/devices/heartbeat', + '/api/v1/me', ])( 'returns 401 before validation-relevant request data can matter when Authorization is missing on %s', async (path) => { @@ -65,6 +66,7 @@ describe('ProtectedDeviceAuthMiddleware', () => { '/api/v1/sync/changes', '/api/v1/uploads/prepare', '/api/v1/devices/heartbeat', + '/api/v1/me', ])( 'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s', async (path) => { diff --git a/backend/src/modules/auth/protected-device-auth.middleware.ts b/backend/src/modules/auth/protected-device-auth.middleware.ts index e270bcd..3fd31f0 100644 --- a/backend/src/modules/auth/protected-device-auth.middleware.ts +++ b/backend/src/modules/auth/protected-device-auth.middleware.ts @@ -15,7 +15,10 @@ const PROTECTED_ROUTE_PREFIXES = [ '/api/v1/uploads', ]; -const PROTECTED_ROUTE_EXACT_PATHS = new Set(['/api/v1/devices/heartbeat']); +const PROTECTED_ROUTE_EXACT_PATHS = new Set([ + '/api/v1/devices/heartbeat', + '/api/v1/me', +]); @Injectable() export class ProtectedDeviceAuthMiddleware implements NestMiddleware { diff --git a/backend/src/modules/devices/devices.service.spec.ts b/backend/src/modules/devices/devices.service.spec.ts index 9425bb4..be5e622 100644 --- a/backend/src/modules/devices/devices.service.spec.ts +++ b/backend/src/modules/devices/devices.service.spec.ts @@ -39,7 +39,9 @@ describe('DevicesService', () => { appVersion: '0.1.0', }); - expect(ownerContext.resolve).toHaveBeenCalledTimes(1); + expect(ownerContext.resolve).toHaveBeenCalledWith({ + allowLegacyDeviceFallback: false, + }); expect(prismaService.device.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: ownerId, @@ -52,6 +54,50 @@ describe('DevicesService', () => { expect(response.deviceAccessToken).toBe('device-access-token'); }); + it('does not allow raw deviceId fallback to choose the registration owner', async () => { + const ownerId = randomUUID(); + const prismaService = { + device: { + create: jest.fn().mockImplementation(async ({ data }) => ({ + id: randomUUID(), + createdAt: new Date(), + updatedAt: new Date(), + ...data, + })), + }, + } as any; + const ownerContext = { + resolve: jest.fn().mockResolvedValue({ + userId: ownerId, + }), + } as any; + const deviceAuthService = { + generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'), + hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'), + getAuthenticatedDeviceOrThrow: jest.fn(), + } as any; + const service = new DevicesService( + prismaService, + ownerContext as OwnerContext, + deviceAuthService as DeviceAuthService, + ); + + await service.register({ + platform: 'IPHONE', + deviceName: 'Velody iPhone', + appVersion: '0.1.0', + }); + + expect(ownerContext.resolve).toHaveBeenCalledWith({ + allowLegacyDeviceFallback: false, + }); + expect(prismaService.device.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: ownerId, + }), + }); + }); + it('rejects heartbeat updates for a foreign-owner device', async () => { const deviceId = randomUUID(); const prismaService = { diff --git a/backend/src/modules/devices/devices.service.ts b/backend/src/modules/devices/devices.service.ts index 45352f9..926998d 100644 --- a/backend/src/modules/devices/devices.service.ts +++ b/backend/src/modules/devices/devices.service.ts @@ -28,7 +28,9 @@ export class DevicesService { .digest('hex'); const tokenHash = this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken); - const owner = await this.ownerContext.resolve(); + const owner = await this.ownerContext.resolve({ + allowLegacyDeviceFallback: false, + }); const device = await this.prismaService.device.create({ data: { diff --git a/backend/src/modules/users/account-migration.spec.ts b/backend/src/modules/users/account-migration.spec.ts new file mode 100644 index 0000000..f65a4c3 --- /dev/null +++ b/backend/src/modules/users/account-migration.spec.ts @@ -0,0 +1,25 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +describe('account foundations migration', () => { + it('backfills default owner account metadata and library namespaces', async () => { + const migrationSql = await readFile( + join( + process.cwd(), + 'prisma/migrations/20260623110000_milestone112_account_foundations/migration.sql', + ), + 'utf8', + ); + + expect(migrationSql).toContain( + `"account_kind" = 'LEGACY_DEFAULT'::"UserAccountKind"`, + ); + expect(migrationSql).toContain( + `"account_status" = 'ACTIVE'::"UserAccountStatus"`, + ); + expect(migrationSql).toContain( + `"library_namespace" = COALESCE("library_namespace", gen_random_uuid())`, + ); + expect(migrationSql).toContain(`WHERE "slug" = 'default-owner'`); + }); +}); diff --git a/backend/src/modules/users/account.controller.ts b/backend/src/modules/users/account.controller.ts new file mode 100644 index 0000000..7ec00cd --- /dev/null +++ b/backend/src/modules/users/account.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { DeviceAuthGuard } from '../auth/device-auth.guard'; +import { CurrentAccountResponseDto } from './account.dto'; +import { AccountService } from './account.service'; + +@ApiTags('account') +@Controller({ + version: '1', +}) +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @Get('me') + @UseGuards(DeviceAuthGuard) + @ApiBearerAuth() + @ApiOkResponse({ type: CurrentAccountResponseDto }) + async getMe(): Promise { + return this.accountService.getCurrentAccount(); + } +} diff --git a/backend/src/modules/users/account.dto.ts b/backend/src/modules/users/account.dto.ts new file mode 100644 index 0000000..63a1f0a --- /dev/null +++ b/backend/src/modules/users/account.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserAccountKind, UserAccountStatus } from '@prisma/client'; + +export class CurrentAccountResponseDto { + @ApiProperty({ format: 'uuid' }) + accountId!: string; + + @ApiProperty({ enum: UserAccountKind, example: UserAccountKind.LEGACY_DEFAULT }) + accountKind!: UserAccountKind; + + @ApiProperty({ enum: UserAccountStatus, example: UserAccountStatus.ACTIVE }) + accountStatus!: UserAccountStatus; + + @ApiProperty({ format: 'uuid' }) + libraryNamespace!: string; + + @ApiProperty({ format: 'uuid' }) + currentDeviceId!: string; +} diff --git a/backend/src/modules/users/account.service.spec.ts b/backend/src/modules/users/account.service.spec.ts new file mode 100644 index 0000000..5988ef8 --- /dev/null +++ b/backend/src/modules/users/account.service.spec.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto'; +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { DeviceAuthService } from '../auth/device-auth.service'; +import { AccountService } from './account.service'; + +describe('AccountService', () => { + it('returns the authenticated device owner account metadata', async () => { + const accountId = randomUUID(); + const currentDeviceId = randomUUID(); + const libraryNamespace = randomUUID(); + const prismaService = { + user: { + findUnique: jest.fn().mockResolvedValue({ + id: accountId, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', + libraryNamespace, + }), + }, + } as any; + const deviceAuthService = { + getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({ + deviceId: currentDeviceId, + userId: accountId, + }), + } as any; + const service = new AccountService( + prismaService, + deviceAuthService as DeviceAuthService, + ); + + await expect(service.getCurrentAccount()).resolves.toEqual({ + accountId, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', + libraryNamespace, + currentDeviceId, + }); + expect(prismaService.user.findUnique).toHaveBeenCalledWith({ + where: { + id: accountId, + }, + select: { + id: true, + accountKind: true, + accountStatus: true, + libraryNamespace: true, + }, + }); + }); + + it('requires device bearer authentication', async () => { + const service = new AccountService( + { + user: { + findUnique: jest.fn(), + }, + } as any, + { + getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => { + throw new UnauthorizedException('Authorization header is required'); + }), + } as any, + ); + + await expect(service.getCurrentAccount()).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('returns 404 when the authenticated device owner no longer exists', async () => { + const service = new AccountService( + { + user: { + findUnique: jest.fn().mockResolvedValue(null), + }, + } as any, + { + getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({ + deviceId: randomUUID(), + userId: randomUUID(), + }), + } as any, + ); + + await expect(service.getCurrentAccount()).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/backend/src/modules/users/account.service.ts b/backend/src/modules/users/account.service.ts new file mode 100644 index 0000000..6e23dfa --- /dev/null +++ b/backend/src/modules/users/account.service.ts @@ -0,0 +1,41 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { DeviceAuthService } from '../auth/device-auth.service'; +import { CurrentAccountResponseDto } from './account.dto'; + +@Injectable() +export class AccountService { + constructor( + private readonly prismaService: PrismaService, + private readonly deviceAuthService: DeviceAuthService, + ) {} + + async getCurrentAccount(): Promise { + const authenticatedDevice = + this.deviceAuthService.getAuthenticatedDeviceOrThrow(); + + const account = await this.prismaService.user.findUnique({ + where: { + id: authenticatedDevice.userId, + }, + select: { + id: true, + accountKind: true, + accountStatus: true, + libraryNamespace: true, + }, + }); + + if (!account) { + throw new NotFoundException('Account not found'); + } + + return { + accountId: account.id, + accountKind: account.accountKind, + accountStatus: account.accountStatus, + libraryNamespace: account.libraryNamespace, + currentDeviceId: authenticatedDevice.deviceId, + }; + } +} diff --git a/backend/src/modules/users/default-user.service.spec.ts b/backend/src/modules/users/default-user.service.spec.ts index c3b3aee..06ffdd1 100644 --- a/backend/src/modules/users/default-user.service.spec.ts +++ b/backend/src/modules/users/default-user.service.spec.ts @@ -8,6 +8,9 @@ describe('DefaultUserService', () => { slug: DefaultUserService.defaultOwnerSlug, displayName: DefaultUserService.defaultOwnerDisplayName, isDefault: true, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', + libraryNamespace: randomUUID(), libraryCursor: 0n, createdAt: new Date(), updatedAt: new Date(), @@ -27,11 +30,15 @@ describe('DefaultUserService', () => { update: { displayName: DefaultUserService.defaultOwnerDisplayName, isDefault: true, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', }, create: { slug: DefaultUserService.defaultOwnerSlug, displayName: DefaultUserService.defaultOwnerDisplayName, isDefault: true, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', }, }); }); @@ -49,6 +56,9 @@ describe('DefaultUserService', () => { slug: DefaultUserService.defaultOwnerSlug, displayName: DefaultUserService.defaultOwnerDisplayName, isDefault: true, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', + libraryNamespace: randomUUID(), libraryCursor: 0n, createdAt: new Date(), updatedAt: new Date(), diff --git a/backend/src/modules/users/default-user.service.ts b/backend/src/modules/users/default-user.service.ts index 0872bed..8c422a6 100644 --- a/backend/src/modules/users/default-user.service.ts +++ b/backend/src/modules/users/default-user.service.ts @@ -1,5 +1,5 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; -import { User } from '@prisma/client'; +import { User, UserAccountKind, UserAccountStatus } from '@prisma/client'; import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() @@ -21,11 +21,15 @@ export class DefaultUserService implements OnApplicationBootstrap { update: { displayName: DefaultUserService.defaultOwnerDisplayName, isDefault: true, + accountKind: UserAccountKind.LEGACY_DEFAULT, + accountStatus: UserAccountStatus.ACTIVE, }, create: { slug: DefaultUserService.defaultOwnerSlug, displayName: DefaultUserService.defaultOwnerDisplayName, isDefault: true, + accountKind: UserAccountKind.LEGACY_DEFAULT, + accountStatus: UserAccountStatus.ACTIVE, }, }); } diff --git a/backend/src/modules/users/owner-context.service.spec.ts b/backend/src/modules/users/owner-context.service.spec.ts index 300d825..d6b2605 100644 --- a/backend/src/modules/users/owner-context.service.spec.ts +++ b/backend/src/modules/users/owner-context.service.spec.ts @@ -103,4 +103,40 @@ describe('BootstrapOwnerContextService', () => { }); expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled(); }); + + it('uses the bootstrap default owner when legacy device fallback is disabled', async () => { + const requestContext = new RequestContextService(); + const defaultUser = { + id: randomUUID(), + slug: 'default-owner', + displayName: 'Default Owner', + isDefault: true, + }; + const defaultUserService = { + getOrCreateDefaultUser: jest.fn().mockResolvedValue(defaultUser), + } as any; + const prismaService = { + device: { + findUnique: jest.fn(), + }, + } as any; + const service = new BootstrapOwnerContextService( + defaultUserService, + requestContext, + prismaService, + ); + + await requestContext.run(async () => { + requestContext.setLegacyDeviceId(randomUUID()); + + await expect( + service.resolve({ allowLegacyDeviceFallback: false }), + ).resolves.toEqual({ + userId: defaultUser.id, + }); + }); + + expect(prismaService.device.findUnique).not.toHaveBeenCalled(); + expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1); + }); }); diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index a2f37d5..860718f 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -1,6 +1,9 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { RequestContextModule } from '../../infrastructure/request-context/request-context.module'; +import { AuthModule } from '../auth/auth.module'; +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; import { DefaultUserService } from './default-user.service'; import { BootstrapOwnerContextService, @@ -8,8 +11,10 @@ import { } from './owner-context.service'; @Module({ - imports: [PrismaModule, RequestContextModule], + imports: [PrismaModule, RequestContextModule, AuthModule], + controllers: [AccountController], providers: [ + AccountService, DefaultUserService, BootstrapOwnerContextService, { @@ -17,6 +22,6 @@ import { useExisting: BootstrapOwnerContextService, }, ], - exports: [DefaultUserService, OwnerContext], + exports: [DefaultUserService, OwnerContext, AccountService], }) export class UsersModule {} diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index 29da39e..b29dcf7 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -28,6 +28,7 @@ import { SyncController } from '../../src/modules/sync/sync.controller'; import { UploadsController } from '../../src/modules/uploads/uploads.controller'; import { UploadsService } from '../../src/modules/uploads/uploads.service'; import { PrismaService } from '../../src/infrastructure/database/prisma.service'; +import { AccountController } from '../../src/modules/users/account.controller'; function sampleMp3Bytes(seed: string): Buffer { return Buffer.concat([ @@ -98,6 +99,9 @@ function createPrismaMock() { id: data.id ?? randomUUID(), createdAt: data.createdAt ?? now, updatedAt: data.updatedAt ?? now, + accountKind: data.accountKind ?? 'LEGACY_DEFAULT', + accountStatus: data.accountStatus ?? 'ACTIVE', + libraryNamespace: data.libraryNamespace ?? randomUUID(), ...data, libraryCursor: data.libraryCursor == null ? 0n : BigInt(data.libraryCursor), @@ -138,6 +142,21 @@ function createPrismaMock() { users.set(created.id, created); return created; }), + findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + if (where.id) { + return applySelect(users.get(where.id) ?? null, select); + } + + if (where.slug) { + const matchingUser = + [...users.values()].find((user) => user.slug === where.slug) ?? + null; + + return applySelect(matchingUser, select); + } + + return null; + }), create: jest.fn().mockImplementation(async ({ data }) => { const created = createUserRecord(data); users.set(created.id, created); @@ -437,6 +456,7 @@ describe('Velody API wiring (e2e)', () => { let app: NestExpressApplication; let prismaMock: ReturnType['prismaMock']; let assetsController: AssetsController; + let accountController: AccountController; let artworkController: ArtworkController; let healthController: HealthController; let devicesController: DevicesController; @@ -552,6 +572,7 @@ describe('Velody API wiring (e2e)', () => { await app.init(); assetsController = moduleRef.get(AssetsController); + accountController = moduleRef.get(AccountController); artworkController = moduleRef.get(ArtworkController); healthController = moduleRef.get(HealthController); devicesController = moduleRef.get(DevicesController); @@ -587,13 +608,22 @@ describe('Velody API wiring (e2e)', () => { update: { displayName: 'Default Owner', isDefault: true, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', }, create: { slug: 'default-owner', displayName: 'Default Owner', isDefault: true, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', }, }); + expect(prismaState.defaultUser).toMatchObject({ + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', + libraryNamespace: expect.any(String), + }); }); it('registers a device and accepts heartbeat', async () => { @@ -631,6 +661,32 @@ describe('Velody API wiring (e2e)', () => { ); }); + it('returns current account metadata from device bearer auth only', async () => { + const registerResponse = await devicesController.register({ + platform: 'MACOS', + deviceName: 'Account Mac', + appVersion: '0.1.0', + }); + + const meResponse = await runAsDevice(registerResponse.deviceAccessToken, () => + accountController.getMe(), + ); + + expect(meResponse).toEqual({ + accountId: prismaState.defaultUser.id, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', + libraryNamespace: prismaState.defaultUser.libraryNamespace, + currentDeviceId: registerResponse.deviceId, + }); + }); + + it('returns 401 from /me when Authorization is missing', async () => { + await expect(accountController.getMe()).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + it('registers a linked device under the authenticated device owner when Authorization is present', async () => { const linkedOwnerId = randomUUID(); const existingDevice = seedDevice({ @@ -659,6 +715,78 @@ describe('Velody API wiring (e2e)', () => { ); }); + it('ignores raw deviceId owner fallback during unauthenticated registration', async () => { + const foreignOwnerId = randomUUID(); + const foreignDeviceId = randomUUID(); + prismaState.users.set( + foreignOwnerId, + { + id: foreignOwnerId, + slug: 'foreign-owner', + displayName: 'Foreign Owner', + isDefault: false, + accountKind: 'LEGACY_DEFAULT', + accountStatus: 'ACTIVE', + libraryNamespace: randomUUID(), + libraryCursor: 0n, + createdAt: new Date(), + updatedAt: new Date(), + }, + ); + seedDevice({ + userId: foreignOwnerId, + deviceId: foreignDeviceId, + deviceName: 'Foreign Existing Device', + }); + + const response = await requestContextService.run(async () => { + requestContextService.setLegacyDeviceId(foreignDeviceId); + + return devicesController.register({ + platform: 'IPHONE', + deviceName: 'Fresh iPhone', + appVersion: '0.1.0', + }); + }); + + expect(prismaState.devices.get(response.deviceId)?.userId).toBe( + prismaState.defaultUser.id, + ); + }); + + it('uses authenticated device owner during registration even when raw deviceId points elsewhere', async () => { + const authenticatedOwnerId = randomUUID(); + const rawDeviceOwnerId = randomUUID(); + const rawDeviceId = randomUUID(); + const authenticatedDevice = seedDevice({ + userId: authenticatedOwnerId, + deviceAccessToken: 'authenticated-link-token', + deviceName: 'Authenticated Existing Device', + }); + seedDevice({ + userId: rawDeviceOwnerId, + deviceId: rawDeviceId, + deviceName: 'Raw Device', + }); + + const response = await requestContextService.run(async () => { + requestContextService.setLegacyDeviceId(rawDeviceId); + await deviceAuthService.authenticateAuthorizationHeader( + `Bearer ${authenticatedDevice.deviceAccessToken}`, + ); + + return devicesController.register({ + platform: 'IPHONE', + deviceName: 'Linked iPhone', + appVersion: '0.1.0', + }); + }); + + expect(prismaState.devices.get(response.deviceId)?.userId).toBe( + authenticatedOwnerId, + ); + }); + it('returns 401 when register receives an invalid Authorization header', async () => { await expect( registerDeviceWithAuthorization(