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 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,7 @@ export type Database = {
|
|||||||
name: string
|
name: string
|
||||||
platform: Database["public"]["Enums"]["device_platform"]
|
platform: Database["public"]["Enums"]["device_platform"]
|
||||||
public_key: string | null
|
public_key: string | null
|
||||||
|
revoked_at: string | null
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
@@ -128,6 +129,7 @@ export type Database = {
|
|||||||
name: string
|
name: string
|
||||||
platform: Database["public"]["Enums"]["device_platform"]
|
platform: Database["public"]["Enums"]["device_platform"]
|
||||||
public_key?: string | null
|
public_key?: string | null
|
||||||
|
revoked_at?: string | null
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
@@ -137,6 +139,7 @@ export type Database = {
|
|||||||
name?: string
|
name?: string
|
||||||
platform?: Database["public"]["Enums"]["device_platform"]
|
platform?: Database["public"]["Enums"]["device_platform"]
|
||||||
public_key?: string | null
|
public_key?: string | null
|
||||||
|
revoked_at?: string | null
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
@@ -481,6 +484,7 @@ export type Database = {
|
|||||||
Functions: {
|
Functions: {
|
||||||
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
||||||
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
||||||
|
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
|
||||||
attachment_object_conv_id: {
|
attachment_object_conv_id: {
|
||||||
Args: { object_name: string }
|
Args: { object_name: string }
|
||||||
Returns: string
|
Returns: string
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ export interface DeviceRecord {
|
|||||||
name: string;
|
name: string;
|
||||||
platform: DevicePlatform;
|
platform: DevicePlatform;
|
||||||
lastSeenAt: string;
|
lastSeenAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerDevice(
|
export async function registerDevice(
|
||||||
@@ -32,7 +33,7 @@ export async function registerDevice(
|
|||||||
name: params.name,
|
name: params.name,
|
||||||
platform: params.platform,
|
platform: params.platform,
|
||||||
})
|
})
|
||||||
.select('id, name, platform, last_seen_at')
|
.select('id, name, platform, last_seen_at, revoked_at')
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export async function registerDevice(
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
platform: data.platform,
|
platform: data.platform,
|
||||||
lastSeenAt: data.last_seen_at,
|
lastSeenAt: data.last_seen_at,
|
||||||
|
revokedAt: data.revoked_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
|
|||||||
|
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('devices')
|
.from('devices')
|
||||||
.select('id, name, platform, last_seen_at')
|
.select('id, name, platform, last_seen_at, revoked_at')
|
||||||
.eq('user_id', session.user.id)
|
.eq('user_id', session.user.id)
|
||||||
.order('last_seen_at', { ascending: false });
|
.order('last_seen_at', { ascending: false });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -60,6 +62,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
platform: row.platform,
|
platform: row.platform,
|
||||||
lastSeenAt: row.last_seen_at,
|
lastSeenAt: row.last_seen_at,
|
||||||
|
revokedAt: row.revoked_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,14 @@ export async function touchDeviceLastSeen(
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revokeDevice(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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.
|
// Intentional re-exports so app layers only need @chat-app/shared/auth.
|
||||||
export type { SecretStore } from './secure-storage';
|
export type { SecretStore } from './secure-storage';
|
||||||
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
|
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
|
||||||
|
|||||||
Reference in New Issue
Block a user