Complete account schema foundation

This commit is contained in:
diyaa 2026-06-24 07:36:57 +02:00
parent 538d259b63
commit babea87140
16 changed files with 789 additions and 57 deletions

View File

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

View File

@ -12,6 +12,9 @@ model User {
slug String @unique slug String @unique
displayName String @map("display_name") displayName String @map("display_name")
isDefault Boolean @default(false) @map("is_default") 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") libraryCursor BigInt @default(0) @map("library_cursor")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -22,13 +25,20 @@ model User {
uploadSessions UploadSession[] uploadSessions UploadSession[]
libraryEvents LibraryEvent[] libraryEvents LibraryEvent[]
syncCursors DeviceSyncCursor[] syncCursors DeviceSyncCursor[]
passwordCredential UserPasswordCredential?
oauthIdentities UserOAuthIdentity[]
emailVerificationTokens EmailVerificationToken[]
passwordResetTokens PasswordResetToken[]
deviceLinkHistory DeviceLinkHistory[]
outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser")
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
@@map("users") @@map("users")
} }
model Device { model Device {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid @map("user_id") userId String @map("user_id") @db.Uuid
platform DevicePlatform platform DevicePlatform
deviceName String @map("device_name") deviceName String @map("device_name")
appVersion String @map("app_version") appVersion String @map("app_version")
@ -43,6 +53,9 @@ model Device {
uploadSessions UploadSession[] uploadSessions UploadSession[]
syncCursor DeviceSyncCursor? syncCursor DeviceSyncCursor?
audioAssets AudioAsset[] 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) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId]) @@index([userId])
@ -51,9 +64,9 @@ model Device {
model Track { model Track {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid @map("user_id") userId String @map("user_id") @db.Uuid
primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id") primaryAudioAssetId String? @unique @map("primary_audio_asset_id") @db.Uuid
artworkAssetId String? @db.Uuid @map("artwork_asset_id") artworkAssetId String? @map("artwork_asset_id") @db.Uuid
title String title String
artist String artist String
album String? album String?
@ -78,8 +91,8 @@ model Track {
model AudioAsset { model AudioAsset {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid @map("user_id") userId String @map("user_id") @db.Uuid
trackId String? @db.Uuid @map("track_id") trackId String? @map("track_id") @db.Uuid
sha256 String sha256 String
storageKey String @unique @map("storage_key") storageKey String @unique @map("storage_key")
originalFilename String @map("original_filename") originalFilename String @map("original_filename")
@ -90,7 +103,7 @@ model AudioAsset {
sampleRateHz Int? @map("sample_rate_hz") sampleRateHz Int? @map("sample_rate_hz")
channels Int? channels Int?
durationMs Int? @map("duration_ms") 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") createdAt DateTime @default(now()) @map("created_at")
track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade) track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade)
primaryForTrack Track? @relation("PrimaryAudioAsset") primaryForTrack Track? @relation("PrimaryAudioAsset")
@ -103,7 +116,7 @@ model AudioAsset {
} }
model ArtworkAsset { model ArtworkAsset {
userId String @db.Uuid @map("user_id") userId String @map("user_id") @db.Uuid
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
sha256 String sha256 String
storageKey String @unique @map("storage_key") storageKey String @unique @map("storage_key")
@ -122,10 +135,10 @@ model ArtworkAsset {
model UploadSession { model UploadSession {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid @map("user_id") userId String @map("user_id") @db.Uuid
deviceId String @db.Uuid @map("device_id") deviceId String @map("device_id") @db.Uuid
trackId String? @db.Uuid @map("track_id") trackId String? @map("track_id") @db.Uuid
audioAssetId String? @db.Uuid @map("audio_asset_id") audioAssetId String? @map("audio_asset_id") @db.Uuid
expectedSha256 String @map("expected_sha256") expectedSha256 String @map("expected_sha256")
originalFilename String @map("original_filename") originalFilename String @map("original_filename")
expectedSizeBytes BigInt @map("expected_size_bytes") expectedSizeBytes BigInt @map("expected_size_bytes")
@ -146,25 +159,25 @@ model UploadSession {
model LibraryEvent { model LibraryEvent {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
userId String @db.Uuid @map("user_id") userId String @map("user_id") @db.Uuid
cursor BigInt cursor BigInt
entityType EntityType @map("entity_type") entityType EntityType @map("entity_type")
entityId String @db.Uuid @map("entity_id") entityId String @map("entity_id") @db.Uuid
action EventAction action EventAction
payload Json payload Json
payloadVersion Int @default(1) @map("payload_version") payloadVersion Int @default(1) @map("payload_version")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@unique([userId, cursor]) @@unique([userId, cursor])
@@index([userId])
@@index([userId, cursor]) @@index([userId, cursor])
@@map("library_events") @@map("library_events")
} }
model DeviceSyncCursor { model DeviceSyncCursor {
deviceId String @id @db.Uuid @map("device_id") deviceId String @id @map("device_id") @db.Uuid
userId String @db.Uuid @map("user_id") userId String @map("user_id") @db.Uuid
cursor BigInt @default(0) cursor BigInt @default(0)
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade) device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@ -174,6 +187,105 @@ model DeviceSyncCursor {
@@map("device_sync_cursors") @@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 { enum DevicePlatform {
MACOS MACOS
IPHONE IPHONE

View File

@ -23,6 +23,7 @@ describe('ProtectedDeviceAuthMiddleware', () => {
'/api/v1/uploads/upload-id/file', '/api/v1/uploads/upload-id/file',
'/api/v1/uploads/upload-id/finalize', '/api/v1/uploads/upload-id/finalize',
'/api/v1/devices/heartbeat', '/api/v1/devices/heartbeat',
'/api/v1/me',
])( ])(
'returns 401 before validation-relevant request data can matter when Authorization is missing on %s', 'returns 401 before validation-relevant request data can matter when Authorization is missing on %s',
async (path) => { async (path) => {
@ -65,6 +66,7 @@ describe('ProtectedDeviceAuthMiddleware', () => {
'/api/v1/sync/changes', '/api/v1/sync/changes',
'/api/v1/uploads/prepare', '/api/v1/uploads/prepare',
'/api/v1/devices/heartbeat', '/api/v1/devices/heartbeat',
'/api/v1/me',
])( ])(
'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s', 'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s',
async (path) => { async (path) => {

View File

@ -15,7 +15,10 @@ const PROTECTED_ROUTE_PREFIXES = [
'/api/v1/uploads', '/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() @Injectable()
export class ProtectedDeviceAuthMiddleware implements NestMiddleware { export class ProtectedDeviceAuthMiddleware implements NestMiddleware {

View File

@ -39,7 +39,9 @@ describe('DevicesService', () => {
appVersion: '0.1.0', appVersion: '0.1.0',
}); });
expect(ownerContext.resolve).toHaveBeenCalledTimes(1); expect(ownerContext.resolve).toHaveBeenCalledWith({
allowLegacyDeviceFallback: false,
});
expect(prismaService.device.create).toHaveBeenCalledWith({ expect(prismaService.device.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
userId: ownerId, userId: ownerId,
@ -52,6 +54,50 @@ describe('DevicesService', () => {
expect(response.deviceAccessToken).toBe('device-access-token'); 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 () => { it('rejects heartbeat updates for a foreign-owner device', async () => {
const deviceId = randomUUID(); const deviceId = randomUUID();
const prismaService = { const prismaService = {

View File

@ -28,7 +28,9 @@ export class DevicesService {
.digest('hex'); .digest('hex');
const tokenHash = const tokenHash =
this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken); 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({ const device = await this.prismaService.device.create({
data: { data: {

View File

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

View File

@ -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<CurrentAccountResponseDto> {
return this.accountService.getCurrentAccount();
}
}

View File

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

View File

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

View File

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

View File

@ -8,6 +8,9 @@ describe('DefaultUserService', () => {
slug: DefaultUserService.defaultOwnerSlug, slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName, displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true, isDefault: true,
accountKind: 'LEGACY_DEFAULT',
accountStatus: 'ACTIVE',
libraryNamespace: randomUUID(),
libraryCursor: 0n, libraryCursor: 0n,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@ -27,11 +30,15 @@ describe('DefaultUserService', () => {
update: { update: {
displayName: DefaultUserService.defaultOwnerDisplayName, displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true, isDefault: true,
accountKind: 'LEGACY_DEFAULT',
accountStatus: 'ACTIVE',
}, },
create: { create: {
slug: DefaultUserService.defaultOwnerSlug, slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName, displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true, isDefault: true,
accountKind: 'LEGACY_DEFAULT',
accountStatus: 'ACTIVE',
}, },
}); });
}); });
@ -49,6 +56,9 @@ describe('DefaultUserService', () => {
slug: DefaultUserService.defaultOwnerSlug, slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName, displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true, isDefault: true,
accountKind: 'LEGACY_DEFAULT',
accountStatus: 'ACTIVE',
libraryNamespace: randomUUID(),
libraryCursor: 0n, libraryCursor: 0n,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),

View File

@ -1,5 +1,5 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; 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'; import { PrismaService } from '../../infrastructure/database/prisma.service';
@Injectable() @Injectable()
@ -21,11 +21,15 @@ export class DefaultUserService implements OnApplicationBootstrap {
update: { update: {
displayName: DefaultUserService.defaultOwnerDisplayName, displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true, isDefault: true,
accountKind: UserAccountKind.LEGACY_DEFAULT,
accountStatus: UserAccountStatus.ACTIVE,
}, },
create: { create: {
slug: DefaultUserService.defaultOwnerSlug, slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName, displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true, isDefault: true,
accountKind: UserAccountKind.LEGACY_DEFAULT,
accountStatus: UserAccountStatus.ACTIVE,
}, },
}); });
} }

View File

@ -103,4 +103,40 @@ describe('BootstrapOwnerContextService', () => {
}); });
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled(); 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);
});
}); });

View File

@ -1,6 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { RequestContextModule } from '../../infrastructure/request-context/request-context.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 { DefaultUserService } from './default-user.service';
import { import {
BootstrapOwnerContextService, BootstrapOwnerContextService,
@ -8,8 +11,10 @@ import {
} from './owner-context.service'; } from './owner-context.service';
@Module({ @Module({
imports: [PrismaModule, RequestContextModule], imports: [PrismaModule, RequestContextModule, AuthModule],
controllers: [AccountController],
providers: [ providers: [
AccountService,
DefaultUserService, DefaultUserService,
BootstrapOwnerContextService, BootstrapOwnerContextService,
{ {
@ -17,6 +22,6 @@ import {
useExisting: BootstrapOwnerContextService, useExisting: BootstrapOwnerContextService,
}, },
], ],
exports: [DefaultUserService, OwnerContext], exports: [DefaultUserService, OwnerContext, AccountService],
}) })
export class UsersModule {} export class UsersModule {}

View File

@ -28,6 +28,7 @@ import { SyncController } from '../../src/modules/sync/sync.controller';
import { UploadsController } from '../../src/modules/uploads/uploads.controller'; import { UploadsController } from '../../src/modules/uploads/uploads.controller';
import { UploadsService } from '../../src/modules/uploads/uploads.service'; import { UploadsService } from '../../src/modules/uploads/uploads.service';
import { PrismaService } from '../../src/infrastructure/database/prisma.service'; import { PrismaService } from '../../src/infrastructure/database/prisma.service';
import { AccountController } from '../../src/modules/users/account.controller';
function sampleMp3Bytes(seed: string): Buffer { function sampleMp3Bytes(seed: string): Buffer {
return Buffer.concat([ return Buffer.concat([
@ -98,6 +99,9 @@ function createPrismaMock() {
id: data.id ?? randomUUID(), id: data.id ?? randomUUID(),
createdAt: data.createdAt ?? now, createdAt: data.createdAt ?? now,
updatedAt: data.updatedAt ?? now, updatedAt: data.updatedAt ?? now,
accountKind: data.accountKind ?? 'LEGACY_DEFAULT',
accountStatus: data.accountStatus ?? 'ACTIVE',
libraryNamespace: data.libraryNamespace ?? randomUUID(),
...data, ...data,
libraryCursor: libraryCursor:
data.libraryCursor == null ? 0n : BigInt(data.libraryCursor), data.libraryCursor == null ? 0n : BigInt(data.libraryCursor),
@ -138,6 +142,21 @@ function createPrismaMock() {
users.set(created.id, created); users.set(created.id, created);
return 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 }) => { create: jest.fn().mockImplementation(async ({ data }) => {
const created = createUserRecord(data); const created = createUserRecord(data);
users.set(created.id, created); users.set(created.id, created);
@ -437,6 +456,7 @@ describe('Velody API wiring (e2e)', () => {
let app: NestExpressApplication; let app: NestExpressApplication;
let prismaMock: ReturnType<typeof createPrismaMock>['prismaMock']; let prismaMock: ReturnType<typeof createPrismaMock>['prismaMock'];
let assetsController: AssetsController; let assetsController: AssetsController;
let accountController: AccountController;
let artworkController: ArtworkController; let artworkController: ArtworkController;
let healthController: HealthController; let healthController: HealthController;
let devicesController: DevicesController; let devicesController: DevicesController;
@ -552,6 +572,7 @@ describe('Velody API wiring (e2e)', () => {
await app.init(); await app.init();
assetsController = moduleRef.get(AssetsController); assetsController = moduleRef.get(AssetsController);
accountController = moduleRef.get(AccountController);
artworkController = moduleRef.get(ArtworkController); artworkController = moduleRef.get(ArtworkController);
healthController = moduleRef.get(HealthController); healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController); devicesController = moduleRef.get(DevicesController);
@ -587,13 +608,22 @@ describe('Velody API wiring (e2e)', () => {
update: { update: {
displayName: 'Default Owner', displayName: 'Default Owner',
isDefault: true, isDefault: true,
accountKind: 'LEGACY_DEFAULT',
accountStatus: 'ACTIVE',
}, },
create: { create: {
slug: 'default-owner', slug: 'default-owner',
displayName: 'Default Owner', displayName: 'Default Owner',
isDefault: true, 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 () => { 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 () => { it('registers a linked device under the authenticated device owner when Authorization is present', async () => {
const linkedOwnerId = randomUUID(); const linkedOwnerId = randomUUID();
const existingDevice = seedDevice({ 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 () => { it('returns 401 when register receives an invalid Authorization header', async () => {
await expect( await expect(
registerDeviceWithAuthorization( registerDeviceWithAuthorization(