diff --git a/backend/prisma/migrations/20260624123000_milestone113_device_linking_foundation/migration.sql b/backend/prisma/migrations/20260624123000_milestone113_device_linking_foundation/migration.sql new file mode 100644 index 0000000..3adf64a --- /dev/null +++ b/backend/prisma/migrations/20260624123000_milestone113_device_linking_foundation/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "devices" +ADD COLUMN "linked_at" TIMESTAMP(3), +ADD COLUMN "relinked_at" TIMESTAMP(3); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1aa99c7..64fe45a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -43,6 +43,8 @@ model Device { deviceName String @map("device_name") appVersion String @map("app_version") installTokenHash String @map("install_token_hash") + linkedAt DateTime? @map("linked_at") + relinkedAt DateTime? @map("relinked_at") tokenHash String? @unique @map("token_hash") tokenCreatedAt DateTime? @map("token_created_at") tokenLastUsedAt DateTime? @map("token_last_used_at") diff --git a/backend/src/modules/users/device-linking-migration.spec.ts b/backend/src/modules/users/device-linking-migration.spec.ts new file mode 100644 index 0000000..b920548 --- /dev/null +++ b/backend/src/modules/users/device-linking-migration.spec.ts @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +describe('device linking foundation migration', () => { + it('adds explicit device link timestamps', async () => { + const migrationSql = await readFile( + join( + process.cwd(), + 'prisma/migrations/20260624123000_milestone113_device_linking_foundation/migration.sql', + ), + 'utf8', + ); + + expect(migrationSql).toContain(`ALTER TABLE "devices"`); + expect(migrationSql).toContain(`ADD COLUMN "linked_at" TIMESTAMP(3)`); + expect(migrationSql).toContain(`ADD COLUMN "relinked_at" TIMESTAMP(3)`); + }); +}); diff --git a/backend/src/modules/users/device-linking.service.spec.ts b/backend/src/modules/users/device-linking.service.spec.ts new file mode 100644 index 0000000..a180aae --- /dev/null +++ b/backend/src/modules/users/device-linking.service.spec.ts @@ -0,0 +1,268 @@ +import { randomUUID } from 'node:crypto'; +import { UserAccountStatus } from '@prisma/client'; +import { DeviceAuthService } from '../auth/device-auth.service'; +import { DeviceLinkingService } from './device-linking.service'; + +describe('DeviceLinkingService', () => { + const activeAccount = { + id: randomUUID(), + accountStatus: UserAccountStatus.ACTIVE, + }; + + const buildService = (options?: { + device?: { + id?: string; + userId?: string; + linkedAt?: Date | null; + relinkedAt?: Date | null; + tokenHash?: string | null; + tokenCreatedAt?: Date | null; + tokenLastUsedAt?: Date | null; + tokenRevokedAt?: Date | null; + }; + targetUserId?: string; + linkedByDeviceId?: string; + }) => { + const sourceUserId = options?.device?.userId ?? activeAccount.id; + const targetUserId = options?.targetUserId ?? activeAccount.id; + const linkedByDeviceId = options?.linkedByDeviceId ?? randomUUID(); + const deviceRecord = { + id: options?.device?.id ?? randomUUID(), + userId: sourceUserId, + linkedAt: options?.device?.linkedAt ?? null, + relinkedAt: options?.device?.relinkedAt ?? null, + tokenHash: options?.device?.tokenHash ?? 'old-token-hash', + tokenCreatedAt: + options?.device?.tokenCreatedAt ?? new Date('2026-06-01T00:00:00.000Z'), + tokenLastUsedAt: + options?.device?.tokenLastUsedAt ?? new Date('2026-06-02T00:00:00.000Z'), + tokenRevokedAt: options?.device?.tokenRevokedAt ?? null, + }; + const tx = { + device: { + update: jest.fn().mockImplementation(async ({ data }) => { + if (data.user?.connect?.id) { + deviceRecord.userId = data.user.connect.id; + } + + if (Object.prototype.hasOwnProperty.call(data, 'linkedAt')) { + deviceRecord.linkedAt = data.linkedAt; + } + + if (Object.prototype.hasOwnProperty.call(data, 'relinkedAt')) { + deviceRecord.relinkedAt = data.relinkedAt; + } + + if (Object.prototype.hasOwnProperty.call(data, 'tokenHash')) { + deviceRecord.tokenHash = data.tokenHash; + } + + if (Object.prototype.hasOwnProperty.call(data, 'tokenCreatedAt')) { + deviceRecord.tokenCreatedAt = data.tokenCreatedAt; + } + + if (Object.prototype.hasOwnProperty.call(data, 'tokenLastUsedAt')) { + deviceRecord.tokenLastUsedAt = data.tokenLastUsedAt; + } + + if (Object.prototype.hasOwnProperty.call(data, 'tokenRevokedAt')) { + deviceRecord.tokenRevokedAt = data.tokenRevokedAt; + } + + return { + id: deviceRecord.id, + userId: deviceRecord.userId, + linkedAt: deviceRecord.linkedAt, + relinkedAt: deviceRecord.relinkedAt, + tokenHash: deviceRecord.tokenHash, + tokenCreatedAt: deviceRecord.tokenCreatedAt, + tokenLastUsedAt: deviceRecord.tokenLastUsedAt, + tokenRevokedAt: deviceRecord.tokenRevokedAt, + }; + }), + }, + deviceLinkHistory: { + create: jest.fn().mockResolvedValue({ id: randomUUID() }), + }, + deviceSyncCursor: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + const prismaService = { + device: { + findUnique: jest.fn().mockImplementation(async ({ where }) => { + if (where.id === deviceRecord.id) { + return { + id: deviceRecord.id, + userId: deviceRecord.userId, + linkedAt: deviceRecord.linkedAt, + relinkedAt: deviceRecord.relinkedAt, + }; + } + + if (where.id === linkedByDeviceId) { + return { + id: linkedByDeviceId, + }; + } + + return null; + }), + }, + user: { + findUnique: jest.fn().mockImplementation(async ({ where }) => { + if (where.id === targetUserId) { + return { + id: targetUserId, + accountStatus: UserAccountStatus.ACTIVE, + }; + } + + return null; + }), + }, + $transaction: jest.fn().mockImplementation(async (callback) => callback(tx)), + } as any; + const deviceAuthService = { + generateDeviceAccessToken: jest.fn().mockReturnValue('rotated-device-token'), + hashDeviceAccessToken: jest.fn().mockReturnValue('rotated-device-token-hash'), + } as any; + const service = new DeviceLinkingService( + prismaService, + deviceAuthService as DeviceAuthService, + ); + + return { + service, + prismaService, + deviceAuthService, + tx, + deviceRecord, + targetUserId, + linkedByDeviceId, + }; + }; + + it('links a device to the same owner without creating audit history', async () => { + const { service, tx, deviceRecord, targetUserId } = buildService(); + + const result = await service.linkDeviceToUser({ + deviceId: deviceRecord.id, + targetUserId, + }); + + expect(result.previousUserId).toBe(targetUserId); + expect(result.userId).toBe(targetUserId); + expect(result.ownershipChanged).toBe(false); + expect(result.deviceAccessToken).toBe('rotated-device-token'); + expect(result.linkedAt).toBeInstanceOf(Date); + expect(result.relinkedAt).toBeNull(); + expect(tx.deviceLinkHistory.create).not.toHaveBeenCalled(); + expect(tx.deviceSyncCursor.updateMany).not.toHaveBeenCalled(); + }); + + it('links a device to a different owner and resets the sync cursor ownership', async () => { + const targetUserId = randomUUID(); + const existingLinkedAt = new Date('2026-06-03T00:00:00.000Z'); + const { service, tx, deviceRecord } = buildService({ + device: { + userId: randomUUID(), + linkedAt: existingLinkedAt, + }, + targetUserId, + }); + + const result = await service.linkDeviceToUser({ + deviceId: deviceRecord.id, + targetUserId, + }); + + expect(result.previousUserId).not.toBe(targetUserId); + expect(result.userId).toBe(targetUserId); + expect(result.ownershipChanged).toBe(true); + expect(result.linkedAt).toEqual(existingLinkedAt); + expect(result.relinkedAt).toBeInstanceOf(Date); + expect(tx.deviceSyncCursor.updateMany).toHaveBeenCalledWith({ + where: { + deviceId: deviceRecord.id, + }, + data: { + userId: targetUserId, + cursor: BigInt(0), + }, + }); + }); + + it('rotates a device token by revoking the previous token before issuing a new one', async () => { + const now = new Date('2026-06-24T10:00:00.000Z'); + const client = { + device: { + update: jest.fn().mockResolvedValue({}), + }, + } as any; + const service = new DeviceLinkingService( + {} as any, + { + generateDeviceAccessToken: jest.fn().mockReturnValue('next-device-token'), + hashDeviceAccessToken: jest.fn().mockReturnValue('next-device-token-hash'), + } as any, + ); + + await expect( + service.rotateDeviceToken(client, { id: randomUUID() }, now), + ).resolves.toEqual({ + deviceAccessToken: 'next-device-token', + tokenHash: 'next-device-token-hash', + tokenCreatedAt: now, + }); + + expect(client.device.update).toHaveBeenNthCalledWith(1, { + where: { + id: expect.any(String), + }, + data: { + tokenHash: null, + tokenRevokedAt: now, + }, + }); + expect(client.device.update).toHaveBeenNthCalledWith(2, { + where: { + id: expect.any(String), + }, + data: { + tokenHash: 'next-device-token-hash', + tokenCreatedAt: now, + tokenLastUsedAt: null, + tokenRevokedAt: null, + }, + }); + }); + + it('persists device link history when ownership changes', async () => { + const targetUserId = randomUUID(); + const linkedByDeviceId = randomUUID(); + const { service, tx, deviceRecord } = buildService({ + device: { + userId: randomUUID(), + }, + targetUserId, + linkedByDeviceId, + }); + + await service.linkDeviceToUser({ + deviceId: deviceRecord.id, + targetUserId, + linkedByDeviceId, + linkMethod: 'ACCOUNT_DEVICE_LINK', + }); + + expect(tx.deviceLinkHistory.create).toHaveBeenCalledWith({ + data: { + userId: targetUserId, + deviceId: deviceRecord.id, + linkedByDeviceId, + linkMethod: 'ACCOUNT_DEVICE_LINK', + }, + }); + }); +}); diff --git a/backend/src/modules/users/device-linking.service.ts b/backend/src/modules/users/device-linking.service.ts new file mode 100644 index 0000000..dae3b15 --- /dev/null +++ b/backend/src/modules/users/device-linking.service.ts @@ -0,0 +1,240 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, UserAccountStatus } from '@prisma/client'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { DeviceAuthService } from '../auth/device-auth.service'; + +const DEFAULT_LINK_METHOD = 'INTERNAL_DEVICE_LINK'; + +type DeviceTokenClient = Pick; +type DeviceLinkingTransactionClient = Pick< + Prisma.TransactionClient, + 'device' | 'deviceLinkHistory' | 'deviceSyncCursor' +>; + +interface ValidatedDeviceRecord { + id: string; + userId: string; + linkedAt: Date | null; + relinkedAt: Date | null; +} + +export interface ValidateLinkRequestInput { + deviceId: string; + targetUserId: string; + linkedByDeviceId?: string | null; + linkMethod?: string; +} + +export interface ValidatedLinkRequest { + device: ValidatedDeviceRecord; + targetUserId: string; + linkedByDeviceId: string | null; + linkMethod: string; + ownershipChanged: boolean; +} + +export interface RotatedDeviceTokenResult { + deviceAccessToken: string; + tokenHash: string; + tokenCreatedAt: Date; +} + +export interface LinkDeviceToUserResult extends RotatedDeviceTokenResult { + deviceId: string; + previousUserId: string; + userId: string; + linkedAt: Date | null; + relinkedAt: Date | null; + ownershipChanged: boolean; +} + +@Injectable() +export class DeviceLinkingService { + static readonly defaultLinkMethod = DEFAULT_LINK_METHOD; + + constructor( + private readonly prismaService: PrismaService, + private readonly deviceAuthService: DeviceAuthService, + ) {} + + async validateLinkRequest( + request: ValidateLinkRequestInput, + ): Promise { + const linkMethod = request.linkMethod?.trim() ?? DEFAULT_LINK_METHOD; + + if (!linkMethod) { + throw new BadRequestException('linkMethod is required'); + } + + const [device, targetUser] = await Promise.all([ + this.prismaService.device.findUnique({ + where: { + id: request.deviceId, + }, + select: { + id: true, + userId: true, + linkedAt: true, + relinkedAt: true, + }, + }), + this.prismaService.user.findUnique({ + where: { + id: request.targetUserId, + }, + select: { + id: true, + accountStatus: true, + }, + }), + ]); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + if (!targetUser) { + throw new NotFoundException('Account not found'); + } + + if (targetUser.accountStatus !== UserAccountStatus.ACTIVE) { + throw new BadRequestException('Account is not active'); + } + + let linkedByDeviceId: string | null = null; + if (request.linkedByDeviceId) { + const linkedByDevice = await this.prismaService.device.findUnique({ + where: { + id: request.linkedByDeviceId, + }, + select: { + id: true, + }, + }); + + if (!linkedByDevice) { + throw new NotFoundException('Linking device not found'); + } + + linkedByDeviceId = linkedByDevice.id; + } + + return { + device, + targetUserId: targetUser.id, + linkedByDeviceId, + linkMethod, + ownershipChanged: device.userId !== targetUser.id, + }; + } + + async linkDeviceToUser( + request: ValidateLinkRequestInput, + ): Promise { + const validatedRequest = await this.validateLinkRequest(request); + const now = new Date(); + + return this.prismaService.$transaction(async (tx) => { + const rotatedToken = await this.rotateDeviceToken( + tx, + validatedRequest.device, + now, + ); + const isFirstLink = validatedRequest.device.linkedAt === null; + const updatedDevice = await tx.device.update({ + where: { + id: validatedRequest.device.id, + }, + data: { + user: { + connect: { + id: validatedRequest.targetUserId, + }, + }, + ...(isFirstLink + ? { + linkedAt: now, + } + : { + relinkedAt: now, + }), + }, + select: { + id: true, + userId: true, + linkedAt: true, + relinkedAt: true, + }, + }); + + if (validatedRequest.ownershipChanged) { + await tx.deviceLinkHistory.create({ + data: { + userId: validatedRequest.targetUserId, + deviceId: validatedRequest.device.id, + linkedByDeviceId: validatedRequest.linkedByDeviceId, + linkMethod: validatedRequest.linkMethod, + }, + }); + + await tx.deviceSyncCursor.updateMany({ + where: { + deviceId: validatedRequest.device.id, + }, + data: { + userId: validatedRequest.targetUserId, + cursor: BigInt(0), + }, + }); + } + + return { + deviceId: updatedDevice.id, + previousUserId: validatedRequest.device.userId, + userId: updatedDevice.userId, + linkedAt: updatedDevice.linkedAt, + relinkedAt: updatedDevice.relinkedAt, + ownershipChanged: validatedRequest.ownershipChanged, + ...rotatedToken, + }; + }); + } + + async rotateDeviceToken( + client: DeviceTokenClient, + device: Pick, + now = new Date(), + ): Promise { + const deviceAccessToken = this.deviceAuthService.generateDeviceAccessToken(); + const tokenHash = + this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken); + + await client.device.update({ + where: { + id: device.id, + }, + data: { + tokenHash: null, + tokenRevokedAt: now, + }, + }); + + await client.device.update({ + where: { + id: device.id, + }, + data: { + tokenHash, + tokenCreatedAt: now, + tokenLastUsedAt: null, + tokenRevokedAt: null, + }, + }); + + return { + deviceAccessToken, + tokenHash, + tokenCreatedAt: now, + }; + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index 860718f..73ddd65 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -5,6 +5,7 @@ import { AuthModule } from '../auth/auth.module'; import { AccountController } from './account.controller'; import { AccountService } from './account.service'; import { DefaultUserService } from './default-user.service'; +import { DeviceLinkingService } from './device-linking.service'; import { BootstrapOwnerContextService, OwnerContext, @@ -16,12 +17,13 @@ import { providers: [ AccountService, DefaultUserService, + DeviceLinkingService, BootstrapOwnerContextService, { provide: OwnerContext, useExisting: BootstrapOwnerContextService, }, ], - exports: [DefaultUserService, OwnerContext, AccountService], + exports: [DefaultUserService, OwnerContext, AccountService, DeviceLinkingService], }) export class UsersModule {}