Add device linking foundation

This commit is contained in:
diyaa 2026-06-24 18:52:23 +02:00
parent babea87140
commit 9bffb005be
6 changed files with 534 additions and 1 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE "devices"
ADD COLUMN "linked_at" TIMESTAMP(3),
ADD COLUMN "relinked_at" TIMESTAMP(3);

View File

@ -43,6 +43,8 @@ model Device {
deviceName String @map("device_name") deviceName String @map("device_name")
appVersion String @map("app_version") appVersion String @map("app_version")
installTokenHash String @map("install_token_hash") installTokenHash String @map("install_token_hash")
linkedAt DateTime? @map("linked_at")
relinkedAt DateTime? @map("relinked_at")
tokenHash String? @unique @map("token_hash") tokenHash String? @unique @map("token_hash")
tokenCreatedAt DateTime? @map("token_created_at") tokenCreatedAt DateTime? @map("token_created_at")
tokenLastUsedAt DateTime? @map("token_last_used_at") tokenLastUsedAt DateTime? @map("token_last_used_at")

View File

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

View File

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

View File

@ -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<Prisma.TransactionClient, 'device'>;
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<ValidatedLinkRequest> {
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<LinkDeviceToUserResult> {
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<ValidatedDeviceRecord, 'id'>,
now = new Date(),
): Promise<RotatedDeviceTokenResult> {
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,
};
}
}

View File

@ -5,6 +5,7 @@ import { AuthModule } from '../auth/auth.module';
import { AccountController } from './account.controller'; import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { DefaultUserService } from './default-user.service'; import { DefaultUserService } from './default-user.service';
import { DeviceLinkingService } from './device-linking.service';
import { import {
BootstrapOwnerContextService, BootstrapOwnerContextService,
OwnerContext, OwnerContext,
@ -16,12 +17,13 @@ import {
providers: [ providers: [
AccountService, AccountService,
DefaultUserService, DefaultUserService,
DeviceLinkingService,
BootstrapOwnerContextService, BootstrapOwnerContextService,
{ {
provide: OwnerContext, provide: OwnerContext,
useExisting: BootstrapOwnerContextService, useExisting: BootstrapOwnerContextService,
}, },
], ],
exports: [DefaultUserService, OwnerContext, AccountService], exports: [DefaultUserService, OwnerContext, AccountService, DeviceLinkingService],
}) })
export class UsersModule {} export class UsersModule {}