feat(ext/sw): device management — devices.ts + router handlers
Adds readDevices, addDevice, revokeDevice helpers that read/write .relicario/devices.json. Router handlers: list_devices, add_device, revoke_device. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
62
extension/src/service-worker/__tests__/devices.test.ts
Normal file
62
extension/src/service-worker/__tests__/devices.test.ts
Normal file
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
extension/src/service-worker/devices.ts
Normal file
55
extension/src/service-worker/devices.ts
Normal file
@@ -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<Device[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type { GitHost } from '../git-host';
|
|||||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||||
import * as vault from '../vault';
|
import * as vault from '../vault';
|
||||||
import * as session from '../session';
|
import * as session from '../session';
|
||||||
|
import * as devices from '../devices';
|
||||||
|
|
||||||
// --- Shared ambient state owned by the SW module ---
|
// --- 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 'list_devices':
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
case 'add_device':
|
const list = await devices.readDevices(state.gitHost);
|
||||||
case 'revoke_device':
|
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 'list_trashed':
|
||||||
case 'restore_item':
|
case 'restore_item':
|
||||||
case 'purge_item':
|
case 'purge_item':
|
||||||
|
|||||||
Reference in New Issue
Block a user