From 0003c3e6588cac993ff0a06a64850e9021d7568a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 26 Apr 2026 15:53:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(ext/sw):=20device=20management=20=E2=80=94?= =?UTF-8?q?=20devices.ts=20+=20router=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds readDevices, addDevice, revokeDevice helpers that read/write .relicario/devices.json. Router handlers: list_devices, add_device, revoke_device. Co-Authored-By: Claude --- .../service-worker/__tests__/devices.test.ts | 62 +++++++++++++++++++ extension/src/service-worker/devices.ts | 55 ++++++++++++++++ .../src/service-worker/router/popup-only.ts | 29 +++++++-- 3 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 extension/src/service-worker/__tests__/devices.test.ts create mode 100644 extension/src/service-worker/devices.ts diff --git a/extension/src/service-worker/__tests__/devices.test.ts b/extension/src/service-worker/__tests__/devices.test.ts new file mode 100644 index 0000000..5384f95 --- /dev/null +++ b/extension/src/service-worker/__tests__/devices.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readDevices, addDevice, revokeDevice } from '../devices'; +import type { GitHost } from '../git-host'; + +function makeGitHost(devicesJson = '{"devices":[]}'): GitHost { + let stored = devicesJson; + return { + readFile: vi.fn().mockImplementation(async () => new TextEncoder().encode(stored)), + writeFile: vi.fn().mockImplementation(async (_p, bytes) => { stored = new TextDecoder().decode(bytes); }), + deleteFile: vi.fn(), + listDir: vi.fn(), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), + }; +} + +describe('devices', () => { + it('readDevices returns empty array when file missing', async () => { + const host = makeGitHost(); + (host.readFile as ReturnType).mockRejectedValueOnce(new Error('404')); + const result = await readDevices(host); + expect(result).toEqual([]); + }); + + it('readDevices parses existing devices', async () => { + const host = makeGitHost('{"devices":[{"name":"CLI","public_key":"abc123","added_at":1000}]}'); + const result = await readDevices(host); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('CLI'); + }); + + it('addDevice appends to list', async () => { + const host = makeGitHost(); + await addDevice(host, { name: 'Chrome', public_key: 'def456', added_at: 2000 }); + expect(host.writeFile).toHaveBeenCalled(); + const written = (host.writeFile as ReturnType).mock.calls[0][1]; + const parsed = JSON.parse(new TextDecoder().decode(written)); + expect(parsed.devices).toHaveLength(1); + expect(parsed.devices[0].name).toBe('Chrome'); + }); + + it('addDevice rejects duplicate name', async () => { + const host = makeGitHost('{"devices":[{"name":"Chrome","public_key":"abc","added_at":1000}]}'); + await expect(addDevice(host, { name: 'Chrome', public_key: 'xyz', added_at: 2000 })) + .rejects.toThrow(/already exists/); + }); + + it('revokeDevice removes by name', async () => { + const host = makeGitHost('{"devices":[{"name":"CLI","public_key":"a","added_at":1},{"name":"Chrome","public_key":"b","added_at":2}]}'); + await revokeDevice(host, 'CLI'); + const written = (host.writeFile as ReturnType).mock.calls[0][1]; + const parsed = JSON.parse(new TextDecoder().decode(written)); + expect(parsed.devices).toHaveLength(1); + expect(parsed.devices[0].name).toBe('Chrome'); + }); + + it('revokeDevice throws if not found', async () => { + const host = makeGitHost(); + await expect(revokeDevice(host, 'nonexistent')).rejects.toThrow(/not found/); + }); +}); diff --git a/extension/src/service-worker/devices.ts b/extension/src/service-worker/devices.ts new file mode 100644 index 0000000..1c0ac59 --- /dev/null +++ b/extension/src/service-worker/devices.ts @@ -0,0 +1,55 @@ +/// Device management — reads/writes .relicario/devices.json + +import type { GitHost } from './git-host'; +import type { Device } from '../shared/types'; + +const DEVICES_PATH = '.relicario/devices.json'; + +interface DevicesFile { + devices: Device[]; +} + +export async function readDevices(gitHost: GitHost): Promise { + try { + const raw = await gitHost.readFile(DEVICES_PATH); + const text = new TextDecoder().decode(raw); + const parsed: DevicesFile = JSON.parse(text); + return parsed.devices ?? []; + } catch { + return []; + } +} + +export async function writeDevices( + gitHost: GitHost, + devices: Device[], + message: string, +): Promise { + const content: DevicesFile = { devices }; + const bytes = new TextEncoder().encode(JSON.stringify(content, null, 2)); + await gitHost.writeFile(DEVICES_PATH, bytes, message); +} + +export async function addDevice( + gitHost: GitHost, + device: Device, +): Promise { + const existing = await readDevices(gitHost); + if (existing.some((d) => d.name === device.name)) { + throw new Error(`device '${device.name}' already exists`); + } + existing.push(device); + await writeDevices(gitHost, existing, `device: add ${device.name}`); +} + +export async function revokeDevice( + gitHost: GitHost, + name: string, +): Promise { + const existing = await readDevices(gitHost); + const filtered = existing.filter((d) => d.name !== name); + if (filtered.length === existing.length) { + throw new Error(`device '${name}' not found`); + } + await writeDevices(gitHost, filtered, `device: revoke ${name}`); +} diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index ad3920b..ab576e3 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -10,6 +10,7 @@ import type { GitHost } from '../git-host'; import { createGitHost, base64ToUint8Array } from '../git-host'; import * as vault from '../vault'; import * as session from '../session'; +import * as devices from '../devices'; // --- Shared ambient state owned by the SW module --- // @@ -291,10 +292,30 @@ export async function handle( } } - // Handlers for these cases are added in Tasks 3–5. - case 'list_devices': - case 'add_device': - case 'revoke_device': + case 'list_devices': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const list = await devices.readDevices(state.gitHost); + return { ok: true, data: { devices: list } }; + } + + case 'add_device': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const device = { + name: msg.name, + public_key: msg.public_key, + added_at: Math.floor(Date.now() / 1000), + }; + await devices.addDevice(state.gitHost, device); + return { ok: true }; + } + + case 'revoke_device': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + await devices.revokeDevice(state.gitHost, msg.name); + return { ok: true }; + } + + // Handlers for these cases are added in Tasks 4–5. case 'list_trashed': case 'restore_item': case 'purge_item':