Add device linking foundation
This commit is contained in:
parent
babea87140
commit
9bffb005be
@ -0,0 +1,3 @@
|
||||
ALTER TABLE "devices"
|
||||
ADD COLUMN "linked_at" TIMESTAMP(3),
|
||||
ADD COLUMN "relinked_at" TIMESTAMP(3);
|
||||
@ -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")
|
||||
|
||||
18
backend/src/modules/users/device-linking-migration.spec.ts
Normal file
18
backend/src/modules/users/device-linking-migration.spec.ts
Normal 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)`);
|
||||
});
|
||||
});
|
||||
268
backend/src/modules/users/device-linking.service.spec.ts
Normal file
268
backend/src/modules/users/device-linking.service.spec.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
240
backend/src/modules/users/device-linking.service.ts
Normal file
240
backend/src/modules/users/device-linking.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user