From 5b0aa24a8df01812d523c2d642bff345bdd26f82 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 18:52:34 +0200 Subject: [PATCH] feat(P3.T2): DeviceRecord.revokedAt + revokeDevice wrapper Extend DeviceRecord with revokedAt field, update registerDevice and listOwnDevices selects to include revoked_at, add revokeDevice RPC helper, update db-types to reflect T1 migration schema, and add vitest coverage for all new behaviour. Co-Authored-By: Claude Sonnet 4.6 --- packages/db-types/src/index.ts | 4 ++ packages/shared/src/auth/device.test.ts | 52 +++++++++++++++++++++++++ packages/shared/src/auth/device.ts | 15 ++++++- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/auth/device.test.ts diff --git a/packages/db-types/src/index.ts b/packages/db-types/src/index.ts index b6b8922..854c061 100644 --- a/packages/db-types/src/index.ts +++ b/packages/db-types/src/index.ts @@ -119,6 +119,7 @@ export type Database = { name: string platform: Database["public"]["Enums"]["device_platform"] public_key: string | null + revoked_at: string | null user_id: string } Insert: { @@ -128,6 +129,7 @@ export type Database = { name: string platform: Database["public"]["Enums"]["device_platform"] public_key?: string | null + revoked_at?: string | null user_id: string } Update: { @@ -137,6 +139,7 @@ export type Database = { name?: string platform?: Database["public"]["Enums"]["device_platform"] public_key?: string | null + revoked_at?: string | null user_id?: string } Relationships: [] @@ -481,6 +484,7 @@ export type Database = { Functions: { accept_dm: { Args: { conversation_id: string }; Returns: undefined } are_friends: { Args: { a: string; b: string }; Returns: boolean } + revoke_device: { Args: { p_device_id: string }; Returns: undefined } attachment_object_conv_id: { Args: { object_name: string } Returns: string diff --git a/packages/shared/src/auth/device.test.ts b/packages/shared/src/auth/device.test.ts new file mode 100644 index 0000000..f9bec65 --- /dev/null +++ b/packages/shared/src/auth/device.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { listOwnDevices, revokeDevice } from './device'; + +function makeClient(overrides: { + user?: { id: string } | null; + selectData?: Array<{ id: string; name: string; platform: string; last_seen_at: string; revoked_at: string | null }>; + rpcImpl?: (fn: string, params: unknown) => Promise<{ data: unknown; error: unknown }>; +}): any { + const builder: any = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockResolvedValue({ data: overrides.selectData ?? [], error: null }), + }; + return { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: overrides.user ?? { id: 'u-1' } } }) }, + from: vi.fn().mockReturnValue(builder), + rpc: vi.fn().mockImplementation(overrides.rpcImpl ?? (async () => ({ data: null, error: null }))), + }; +} + +describe('listOwnDevices', () => { + it('maps revoked_at into revokedAt', async () => { + const client = makeClient({ + selectData: [ + { id: 'd-1', name: 'Laptop', platform: 'desktop', last_seen_at: '2026-05-16T00:00:00Z', revoked_at: null }, + { id: 'd-2', name: 'Old phone', platform: 'mobile', last_seen_at: '2026-05-10T00:00:00Z', revoked_at: '2026-05-15T12:00:00Z' }, + ], + }); + const out = await listOwnDevices(client); + expect(out).toEqual([ + { id: 'd-1', name: 'Laptop', platform: 'desktop', lastSeenAt: '2026-05-16T00:00:00Z', revokedAt: null }, + { id: 'd-2', name: 'Old phone', platform: 'mobile', lastSeenAt: '2026-05-10T00:00:00Z', revokedAt: '2026-05-15T12:00:00Z' }, + ]); + }); +}); + +describe('revokeDevice', () => { + it('invokes the revoke_device RPC with the device id', async () => { + const rpc = vi.fn().mockResolvedValue({ data: null, error: null }); + const client = makeClient({ rpcImpl: rpc }); + await revokeDevice(client, 'd-42'); + expect(rpc).toHaveBeenCalledWith('revoke_device', { p_device_id: 'd-42' }); + }); + + it('throws when the RPC returns an error', async () => { + const client = makeClient({ + rpcImpl: async () => ({ data: null, error: { message: 'not authorized', code: '42501' } as any }), + }); + await expect(revokeDevice(client, 'd-99')).rejects.toThrow(/not authorized/); + }); +}); diff --git a/packages/shared/src/auth/device.ts b/packages/shared/src/auth/device.ts index 6e9f389..8e0cf50 100644 --- a/packages/shared/src/auth/device.ts +++ b/packages/shared/src/auth/device.ts @@ -16,6 +16,7 @@ export interface DeviceRecord { name: string; platform: DevicePlatform; lastSeenAt: string; + revokedAt: string | null; } export async function registerDevice( @@ -32,7 +33,7 @@ export async function registerDevice( name: params.name, platform: params.platform, }) - .select('id, name, platform, last_seen_at') + .select('id, name, platform, last_seen_at, revoked_at') .single(); if (error) throw error; @@ -41,6 +42,7 @@ export async function registerDevice( name: data.name, platform: data.platform, lastSeenAt: data.last_seen_at, + revokedAt: data.revoked_at, }; } @@ -50,7 +52,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise { + const { error } = await client.rpc('revoke_device', { p_device_id: deviceId }); + if (error) throw new Error(error.message); +} + // Intentional re-exports so app layers only need @chat-app/shared/auth. export type { SecretStore } from './secure-storage'; export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };